diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 735986c355..e966971788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,14 @@ name: CI +# Quality gates for every push to main and every pull request. The Linux gate runs through the +# suite's shared gate workflow in craft-common-actions (`cargo xtask ci`); the Windows legs, the +# test variant and the corpus, web and fuzz jobs are PhotoCraft's own. on: push: branches: [main] pull_request: - types: [opened, synchronize, reopened, ready_for_review] + # `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it. + types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: fuzz: @@ -15,6 +19,10 @@ on: description: "Seconds per fuzz target" default: "600" +# The token is only used to check out the code: no job writes to the repository. +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 @@ -22,51 +30,72 @@ env: # bypasses that, lp fails loudly on a printer that does not exist. PHOTOCRAFT_NO_PRINT: "1" LPDEST: photocraft-no-such-printer - # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the - # debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of - # disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job). - CARGO_PROFILE_DEV_DEBUG: line-tables-only -# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge -# that lands while main's run is in progress waits for it, so every main state that gets a run is -# checked to the end. (Cancelling here left main unverified whenever merges came close together.) +# A newer push to a PR branch cancels its older run. Every push to main gets a run of its own that +# is never cancelled, so each state of main is checked to the end. Adding any label but `full-ci` +# gets a group of its own, so it neither runs anything nor cancels a run. concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and - # Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by - # platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile. - # macOS is tested only by release.yml. + # Whether the Windows legs and the web build run. main, manual runs and PRs labelled `full-ci` + # get both. Other PRs get Windows when they touch code whose behaviour differs by platform: the + # app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or the lockfile. + # Format changes must exercise the Windows-only publication barriers (#8034). macOS is tested + # only by release.yml. platforms: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' timeout-minutes: 10 name: select platforms runs-on: ubuntu-latest outputs: - legs: ${{ steps.pick.outputs.legs }} + windows: ${{ steps.pick.outputs.windows }} web: ${{ steps.pick.outputs.web }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY - id: pick env: EVENT: ${{ github.event_name }} BASE: ${{ github.event.pull_request.base.sha }} FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }} run: | - # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and - # release.yml builds and tests macOS (a failing release is fixed and rebuilt). - # Windows tests run as two legs in parallel: the unit tests and the integration tests. As - # one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting - # the integration tests over a third runner (nextest --partition) saved ~2 min of wall - # time but cost ~7 runner-minutes and one more Windows slot per PR. - linux='{"os":"ubuntu-latest","part":"all"}' - win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; } - all="[$linux,$(win unit),$(win integration)]" if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then - echo "legs=$all" >> "$GITHUB_OUTPUT" + echo "windows=true" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -78,67 +107,100 @@ jobs: else echo "web=false" >> "$GITHUB_OUTPUT" fi - if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then - echo "legs=$all" >> "$GITHUB_OUTPUT" + if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then + echo "windows=true" >> "$GITHUB_OUTPUT" else - echo "legs=[$linux]" >> "$GITHUB_OUTPUT" + echo "windows=false" >> "$GITHUB_OUTPUT" fi - test: - timeout-minutes: 100 - name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }}) + # The Linux gate. Its rust-cache entry is the one `test (without heif)` restores. + gate: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # egui/winit/rfd. + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev + # The tests build with the optional craft-fonts input (crates/text/build.rs), so the + # Japanese font tests that skip without it run. Same commit as release.yml. + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + # Cargo.toml's dev profile already keeps line tables only. + dev-debug: "" + env: | + PHOTOCRAFT_NO_PRINT=1 + LPDEST=photocraft-no-such-printer + timeout-minutes: 100 + # fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with + # `--locked --features heif`, then the missing-translations report (xtask/src/main.rs). + command: cargo xtask ci + + # Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job + # they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration + # tests over a third runner (nextest --partition) saved ~2 min of wall time but cost ~7 + # runner-minutes and one more Windows slot per PR. They stay outside the shared gate for sccache, + # which needs its own action step. + windows: needs: platforms - runs-on: ${{ matrix.os }} + if: needs.platforms.outputs.windows == 'true' + timeout-minutes: 100 + name: test (windows-latest, ${{ matrix.part }}) + runs-on: windows-latest strategy: fail-fast: false matrix: - include: ${{ fromJSON(needs.platforms.outputs.legs) }} + part: [unit, integration] env: # No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes. # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps # line-tables-only, so a backtrace with lines is one OS away). - CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }} + CARGO_PROFILE_DEV_DEBUG: "0" + # The all-language UI tests shape real Japanese, Chinese, Korean and Devanagari labels + # with kittest's strict `MissingGlyphPolicy::Panic`. A hosted Windows image has no + # guaranteed Japanese or Simplified Chinese system font, and egui 0.37 panics on a + # missing glyph; the app's lazy system fallback runs after layout, too late for the + # first headless frame. Use the same pinned central fonts as Linux and release, so every + # language is drawn by a real embedded face rather than skipping. Same commit as the gate + # above and release.yml. + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - # Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the - # background: the space is free before the craft-fonts build needs it, and nothing waits. - - name: Install Linux system dependencies (egui/winit/rfd) - if: runner.os == 'Linux' - run: | - nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ - /opt/hostedtoolcache/CodeQL /dev/null 2>&1 & - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev + - name: Check out craft-fonts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + path: craft-fonts + persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: - components: clippy, rustfmt + components: clippy - # Only main saves caches; PRs restore main's. Every PR used to save its own set (up to - # 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's. + # Only main saves caches; PRs restore main's, so the repo's 10 GB cache budget keeps main's. # A failing test still saves: the cache holds compiled dependencies, not test results. - # The Windows legs share one key (same dependencies); the integration leg saves it, - # because it also runs Clippy and so leaves Clippy's dependency metadata in it. + # Both legs share one key (same dependencies); the integration leg saves it, because it also + # runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: - save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }} + save-if: ${{ github.ref == 'refs/heads/main' && matrix.part == 'integration' }} cache-on-failure: true # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: # warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read # main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688). # Test executables can't be cached, so a PR still links its tests. - - name: Install sccache (Windows) - if: runner.os == 'Windows' + - name: Install sccache uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: version: v0.18.0 - - name: Use sccache (Windows) - if: runner.os == 'Windows' + - name: Use sccache env: MAIN: ${{ github.ref == 'refs/heads/main' }} run: | @@ -147,149 +209,80 @@ jobs: if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi shell: bash - - name: Format - if: runner.os == 'Linux' - run: cargo fmt --all -- --check - - # Keep formatting, strict Clippy, layering and scorecard checks ahead of tests. - name: Clippy - if: matrix.part == 'all' || matrix.part == 'integration' + if: matrix.part == 'integration' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering - if: matrix.part == 'all' || matrix.part == 'integration' + if: matrix.part == 'integration' run: cargo xtask layers - # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, - # prefs audit). Static: it reads committed files only, no benchmarks run here. - - name: Scorecard up to date - if: runner.os == 'Linux' - run: cargo xtask scorecard --check - - # nextest runs the test binaries in parallel (cargo test runs them one at a time), with the - # GPU tests capped by the `gpu` group in .config/nextest.toml (#2204). - - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: - tool: cargo-nextest@0.9.146 + tool: cargo-nextest@0.9.149 - # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. - # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but - # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so - # they get their own step. - # - # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every + # The unit leg runs `--lib --bins` and the integration leg `--test '*'` (every # integration-test target); the feature selection stays the same, so both legs share the - # dependency cache. The `ci` profile fails fast and terminates hung tests - # (.config/nextest.toml); assertion failures are not retried. + # dependency cache. - name: Test env: PART: ${{ matrix.part }} run: | case "$PART" in unit) targets=(--lib --bins) ;; - integration) targets=(--test '*') ;; - *) targets=(--lib --bins --tests) ;; + *) targets=(--test '*') ;; esac cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" shell: bash - # Every command against adversarial params must return Err, never panic or hang (AGENTS.md - # Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as - # Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency - # features and recompile the whole graph. The `ci` profile terminates it if it hangs. - - name: Panic hunt - if: runner.os == 'Linux' - run: >- - cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests - --run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)' - - name: Doctests - if: matrix.part == 'all' || matrix.part == 'unit' + if: matrix.part == 'unit' run: cargo test --workspace --locked --features heif --doc - # The Windows tests used to be one job named `test (windows-latest)`; they're now legs named - # `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old - # name reporting, so a required status check on it (none today) would keep working. It fails - # unless the whole test matrix and both Linux variants passed. `needs` reports one result - # for all matrix legs; when a PR runs no Windows legs it still checks every Linux result. - windows-gate: - timeout-minutes: 5 - name: test (windows-latest) - needs: [platforms, test, test-variants] - if: ${{ !cancelled() }} - runs-on: ubuntu-latest + # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" error + # for distributors who leave it out. Runs beside the gate and restores the gate's dependency + # cache without saving one: the dependencies are the same, and the 10 GB cache budget is tight + # (#2688). + test-without-heif: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + timeout-minutes: 60 + name: test (without heif) + # The gate's runner: its cache entry is keyed on the OS. + runs-on: ubuntu-24.04 + env: + # The gate sets these; rust-cache hashes CARGO_* and RUST* variables into its key. + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 steps: - - env: - PLATFORMS: ${{ needs.platforms.result }} - TEST: ${{ needs.test.result }} - VARIANTS: ${{ needs.test-variants.result }} - run: | - echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS" - [ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ] + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - # The two other builds of the Linux tests, in parallel with the test job instead of after it (they - # added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is - # that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is - # tight (#2688). - test-variants: - timeout-minutes: 100 - name: test (${{ matrix.variant }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - variant: [without heif, with craft-fonts] - steps: - - uses: actions/checkout@v4 - - - name: Install Linux system dependencies (egui/winit/rfd) - run: | - nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ - /opt/hostedtoolcache/CodeQL /dev/null 2>&1 & - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev + - 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 + # gate.yml keys its cache `-rust--gate`. - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: - shared-key: test + prefix-key: v1-rust + shared-key: ${{ hashFiles('Cargo.toml') }}-gate save-if: false - - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: - tool: cargo-nextest@0.9.146 + tool: cargo-nextest@0.9.149 - # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" - # error for distributors who leave it out. - name: Test without heif - if: matrix.variant == 'without heif' run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci - # The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese - # font tests that skip without it run. Keep the test job's exact `--workspace --features heif - # --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds. - # Filter execution to the three crates that read the optional font input (#6127). - - name: Check out craft-fonts - if: matrix.variant == 'with craft-fonts' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - - name: Test with craft-fonts - if: matrix.variant == 'with craft-fonts' - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: >- - cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests - -E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)' - corpus: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' timeout-minutes: 30 # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache @@ -305,7 +298,9 @@ jobs: CARGO_PROFILE_RELEASE_LTO: "false" CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" steps: - - uses: actions/checkout@v4 + - 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: @@ -313,7 +308,7 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true - name: Restore test corpora - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: corpus/ key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }} @@ -322,23 +317,6 @@ jobs: - name: Corpus tests run: cargo xtask test-corpus - wasm: - timeout-minutes: 30 - name: wasm32 check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: wasm32 - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - - name: Check wasm-safe crates - run: cargo xtask wasm - web: timeout-minutes: 60 name: web build and wasm size gate @@ -350,7 +328,9 @@ jobs: # Match release.yml so PR results reflect what would actually ship. TRUNK_VERSION: 0.21.14 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown @@ -408,6 +388,7 @@ jobs: - { crate: psd, name: metadata } - { crate: psd, name: tagged } - { crate: psd, name: resources } + - { crate: psd, name: asl } - { crate: codecs, name: decode_any } - { crate: codecs, name: decode_png } - { crate: codecs, name: decode_jpeg } @@ -418,8 +399,10 @@ jobs: - { crate: affinity, name: import_preview, args: "--features io" } - { crate: format, name: load } steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # nightly - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: crates/${{ matrix.target.crate }}/fuzz @@ -432,7 +415,7 @@ jobs: run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS" - name: Upload crash artifacts if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }} path: crates/${{ matrix.target.crate }}/fuzz/artifacts diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dca0dacfbb..e93efc1018 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -31,7 +31,7 @@ jobs: name: mdBook build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - name: Install mdBook run: cargo install mdbook --version 0.4.52 --locked diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 56d1ad0831..2b98d8c11d 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -9,6 +9,8 @@ name: FreeBSD # or dependency change: a drifted dependency still shows up within a day. PRs that change the # FreeBSD packaging itself still run it. on: + push: + branches: [release] schedule: - cron: "17 2 * * *" pull_request: @@ -30,7 +32,7 @@ jobs: # 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/freebsd.yml@main with: - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 cargo-build-extra-args: "--features heif" # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at # once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests. diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 4b09278804..64592d9cf0 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -40,7 +40,7 @@ jobs: runs-on: macos-15 timeout-minutes: 120 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -72,7 +72,7 @@ jobs: - name: Upload results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: perf-results path: | @@ -83,7 +83,7 @@ jobs: - name: Upload baseline if: always() && inputs.update_baseline - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: perf-baseline path: perf/baseline.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfdca9e3e9..4b78fac526 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: photocraft app-title: PhotoCraft - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d8..8f1aa5ad52 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,10 +11,14 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: - # Tracked at @main so org-wide CI improvements propagate without PR churn. + # Track shared CI improvements on main. uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + with: + timeout-minutes: 120 + fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 secrets: inherit diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 30a07f4cd5..1022c54215 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -7,6 +7,8 @@ name: Windows ARM64 # Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that # change the Windows packaging itself. on: + push: + branches: [release] pull_request: paths: - .github/workflows/windows-arm64.yml diff --git a/AGENTS.md b/AGENTS.md index dbe7e172a9..172bc7bd70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file, 8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log. 9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green. +10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). ## 4. Picking work diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6a38a6a433..931853254c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,9 +25,11 @@ next to them. | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -36,9 +38,11 @@ next to them. | `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -87,6 +91,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `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` | Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in [`docs/images/SOURCES.md`](docs/images/SOURCES.md)): @@ -112,7 +118,7 @@ next to the files: | Path (fetched) | Title | Author | Source | License | |---|---|---|---|---| -| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 | +| `corpus/photoshop/` (258 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 | | `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 | | `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi | | `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) | diff --git a/Cargo.lock b/Cargo.lock index afce9834ef..6ae5d98403 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -15,12 +15,12 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -29,43 +29,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -73,9 +67,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -91,13 +85,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -105,11 +99,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -179,6 +174,15 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -241,12 +245,56 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -261,21 +309,21 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -611,15 +659,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -630,10 +669,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -641,6 +683,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -714,7 +766,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -730,6 +782,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -993,6 +1069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -1001,8 +1078,31 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -1011,6 +1111,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1079,6 +1189,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colored" version = "2.2.0" @@ -1225,12 +1341,20 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" + +[[package]] +name = "craft-jpeg" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +dependencies = [ + "thiserror 2.0.21", +] [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b76109da11416e9ca4ea719#3d37da389a3618ab9b76109da11416e9ca4ea719" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1243,12 +1367,41 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -1259,32 +1412,31 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", - "itertools 0.10.5", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", + "page_size", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -1292,12 +1444,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -1432,6 +1584,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dify" version = "0.8.0" @@ -1470,7 +1628,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1583,9 +1741,8 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1594,9 +1751,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1610,7 +1766,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1619,6 +1775,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1631,9 +1788,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1653,9 +1809,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1673,31 +1828,34 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1710,9 +1868,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1724,9 +1881,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1751,9 +1907,8 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1781,22 +1936,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1833,9 +1988,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1852,16 +2006,17 @@ dependencies = [ "serde", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -1896,7 +2051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2077,7 +2232,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2141,7 +2296,7 @@ dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-text", "objc2-foundation 0.3.2", @@ -2442,9 +2597,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -2488,7 +2643,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3015,7 +3170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -3045,17 +3200,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is-wsl" version = "0.4.0" @@ -3066,6 +3210,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -3075,6 +3225,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3391,12 +3550,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3442,9 +3601,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libfuzzer-sys" @@ -3702,14 +3861,14 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3798,6 +3957,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -3858,6 +4029,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -3939,9 +4116,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3971,7 +4148,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit 0.3.2", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -3992,7 +4169,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-avf-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -4011,7 +4188,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4035,7 +4212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4057,7 +4234,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -4069,7 +4246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -4091,7 +4268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4105,7 +4282,7 @@ dependencies = [ "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -4118,7 +4295,7 @@ dependencies = [ "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", "objc2-metal 0.3.2", @@ -4142,7 +4319,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4167,7 +4344,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -4181,7 +4358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -4194,7 +4371,7 @@ checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -4229,7 +4406,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4239,7 +4416,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -4251,7 +4428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4273,7 +4450,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", "objc2-core-media", @@ -4299,7 +4476,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4323,7 +4500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4368,7 +4545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -4403,6 +4580,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -4411,9 +4594,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "is-wsl", "libc", @@ -4461,7 +4644,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", ] [[package]] @@ -4595,9 +4788,9 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -4606,9 +4799,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -4616,9 +4809,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -4629,20 +4822,25 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] [[package]] name = "photocraft" -version = "0.12.0" +version = "0.16.0" dependencies = [ "arboard", "async-io", + "base64 0.22.1", + "blake3", + "chrono", + "clap", "core-graphics2", + "craft-update", "dispatch2", "eframe", "egui", @@ -4650,7 +4848,7 @@ dependencies = [ "futures-lite", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", @@ -4670,10 +4868,12 @@ dependencies = [ "resvg", "rfd", "ron", - "roxmltree 0.20.0", + "roxmltree 0.21.1", "rustix 1.1.5", + "serde", "serde_json", "tempfile", + "thiserror 2.0.21", "winit", "winresource", "winsafe", @@ -4685,7 +4885,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.12.0" +version = "0.16.0" dependencies = [ "crc32fast", "flate2", @@ -4695,11 +4895,12 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.12.0" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", + "photocraft-testkit", "rayon", "rustfft", "serde", @@ -4708,18 +4909,19 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.12.0" +version = "0.16.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "cap-std", + "clap", "getrandom 0.3.4", "photocraft-codecs", "photocraft-compose", "photocraft-doc", "photocraft-engine", "photocraft-format", - "photocraft-geom", "photocraft-io", + "photocraft-raw", "rmcp", "rustix 1.1.5", "schemars", @@ -4731,21 +4933,26 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "clap", + "clap_complete", + "clap_mangen", "photocraft-automation", "photocraft-codecs", "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-raw", "serde_json", + "thiserror 2.0.21", "tokio", "winresource", ] [[package]] name = "photocraft-cms" -version = "0.12.0" +version = "0.16.0" dependencies = [ "moxcms", "rayon", @@ -4753,8 +4960,9 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "craft-jpeg", "exr", "flate2", "half", @@ -4774,7 +4982,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.12.0" +version = "0.16.0" dependencies = [ "photocraft-cms", "serde", @@ -4782,9 +4990,10 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.12.0" +version = "0.16.0" dependencies = [ "criterion", + "photocraft-cms", "photocraft-color", "photocraft-doc", "photocraft-geom", @@ -4797,7 +5006,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.12.0" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4808,10 +5017,11 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.12.0" +version = "0.16.0" dependencies = [ "base64 0.22.1", "blake3", + "chrono", "craft-print", "exr", "flate2", @@ -4832,11 +5042,13 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "photocraft-raw", + "photocraft-testkit", "photocraft-text", "photocraft-vector", "rayon", - "roxmltree 0.20.0", + "roxmltree 0.21.1", "same-file", + "semver", "serde", "serde_json", "tempfile", @@ -4847,7 +5059,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.12.0" +version = "0.16.0" dependencies = [ "blake3", "crc32fast", @@ -4870,17 +5082,19 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.12.0" +version = "0.16.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "bytemuck", "half", "log", + "photocraft-algo", "photocraft-cms", "photocraft-color", "photocraft-compose", @@ -4895,14 +5109,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.12.0" +version = "0.16.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.12.0" +version = "0.16.0" dependencies = [ "blake3", "craft-docpath", @@ -4922,45 +5136,52 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "photocraft-raw", + "photocraft-testkit", "photocraft-text", "photocraft-vector", "proptest", "resvg", - "roxmltree 0.20.0", + "roxmltree 0.21.1", "serde_json", "thiserror 2.0.21", + "tiff", + "zune-core 0.5.3", + "zune-jpegxl", ] [[package]] name = "photocraft-ipp" -version = "0.12.0" +version = "0.16.0" [[package]] name = "photocraft-ops" -version = "0.12.0" +version = "0.16.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "photocraft-cms", "photocraft-color", "photocraft-geom", "photocraft-raster", + "rayon", "serde", "serde_json", ] [[package]] name = "photocraft-plugins" -version = "0.12.0" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", "rayon", + "rustix 1.1.5", "serde", "serde_json", "thiserror 2.0.21", @@ -4970,7 +5191,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.12.0" +version = "0.16.0" dependencies = [ "flate2", "photocraft-psd", @@ -4981,7 +5202,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.12.0" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4992,7 +5213,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.12.0" +version = "0.16.0" dependencies = [ "flate2", "image", @@ -5007,10 +5228,10 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.12.0" +version = "0.16.0" dependencies = [ "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -5019,18 +5240,15 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.12.0" +version = "0.16.0" dependencies = [ "memory-stats", - "photocraft-color", - "photocraft-geom", - "photocraft-raster", "serde_json", ] [[package]] name = "photocraft-text" -version = "0.12.0" +version = "0.16.0" dependencies = [ "flate2", "parley", @@ -5045,11 +5263,12 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.12.0" +version = "0.16.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "craft-print", "craft-ui", + "craft-update", "eframe", "egui", "egui_extras", @@ -5080,14 +5299,16 @@ dependencies = [ "serde", "serde_json", "sys-locale", + "tempfile", "toml", "unicode-bidi", + "web-time", "winsafe", ] [[package]] name = "photocraft-vector" -version = "0.12.0" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5098,7 +5319,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.12.0" +version = "0.16.0" dependencies = [ "eframe", "egui", @@ -5120,7 +5341,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.12.0" +version = "0.16.0" dependencies = [ "flate2", "js-sys", @@ -5209,6 +5430,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -5296,6 +5530,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -5383,16 +5623,17 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -5459,6 +5700,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -5513,7 +5763,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5600,11 +5850,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5683,7 +5933,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5879,7 +6129,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5920,13 +6170,14 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.5.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" +checksum = "ceb42e9e47443cd83a834dea57b5816d35481417bae72ade5dc5615c59122e26" dependencies = [ "chrono", "futures", "indexmap", + "libc", "pastey 0.2.3", "pin-project-lite", "rmcp-macros", @@ -5943,9 +6194,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.5.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e66877136e969d5a2d561ee5841b17717a0619aebd64abb47a2a2f46e34683" +checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389" dependencies = [ "darling", "proc-macro2", @@ -5954,6 +6205,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "ron" version = "0.12.2" @@ -6041,7 +6298,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6056,12 +6313,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -6108,7 +6367,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6206,9 +6465,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", @@ -6220,9 +6479,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", @@ -6276,6 +6535,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -6320,9 +6589,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -6973,10 +7242,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7042,6 +7311,36 @@ dependencies = [ "zune-jpeg 0.5.15", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -7118,9 +7417,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -7356,6 +7655,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" @@ -7457,6 +7762,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -7496,12 +7830,24 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" @@ -7527,9 +7873,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -7542,9 +7888,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -7552,6 +7898,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -7665,12 +8021,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" dependencies = [ "leb128fmt", - "wasmparser 0.261.0", + "wasmparser 0.262.0", ] [[package]] @@ -7725,9 +8081,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ "bitflags 2.13.2", "indexmap", @@ -7736,9 +8092,9 @@ dependencies = [ [[package]] name = "wast" -version = "261.0.0" +version = "262.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" dependencies = [ "bumpalo", "leb128fmt", @@ -7749,9 +8105,9 @@ dependencies = [ [[package]] name = "wat" -version = "1.261.0" +version = "1.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" dependencies = [ "wast", ] @@ -7875,7 +8231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -8069,7 +8425,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -8130,15 +8486,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -8257,7 +8635,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -8266,16 +8644,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -8293,31 +8662,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -8335,101 +8687,52 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -8507,10 +8810,20 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -8639,7 +8952,7 @@ dependencies = [ "image", "libwayshot-xcap", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-av-foundation", "objc2-core-foundation", @@ -8668,7 +8981,7 @@ checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ "bitflags 2.13.2", "libc", - "quick-xml", + "quick-xml 0.41.0", ] [[package]] @@ -8716,8 +9029,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.12.0" +version = "0.16.0" dependencies = [ + "chrono", "serde", "serde_json", "toml", @@ -8965,6 +9279,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -8977,6 +9308,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" @@ -9065,3 +9408,18 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index c3fdd1e10c..a3b0ca4b36 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.12.0" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["serde"] } # internal photocraft-geom = { path = "crates/geom" } photocraft-color = { path = "crates/color" } @@ -39,11 +39,16 @@ photocraft-tablet = { path = "crates/tablet" } photocraft-ipp = { path = "crates/ipp" } # external -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" +clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4.6.11" +clap_mangen = { version = "0.3.3", features = ["env"] } arc-swap = "1" bytemuck = { version = "1", features = ["derive"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } @@ -52,16 +57,20 @@ image = { version = "0.25", default-features = false } zune-core = "0.5" zune-jpeg = "0.5" proptest = "1" -eframe = { version = "0.36", default-features = false } +eframe = { version = "0.37", default-features = false } # Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated # (egui_fonts.rs; the web build's size gate). -egui = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" -criterion = { version = "0.5", default-features = false } +criterion = { version = "0.8", default-features = false } getrandom = "0.3" +# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump). +web-time = "1" [workspace.lints.rust] unsafe_code = "forbid" @@ -70,15 +79,18 @@ unsafe_code = "forbid" dbg_macro = "warn" todo = "warn" +# Dependencies carry no debug info: their frames in a backtrace lose file:line, and the target dir +# and link times shrink. The test profile inherits both settings. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 # Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link -# time and PDB size for the ~260 test binaries (#2204). CI sets the same through -# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a -# debugger, build with CARGO_PROFILE_DEV_DEBUG=true. +# time and PDB size for the test binaries (#2204). CI's Windows legs build with no debuginfo at all +# (CARGO_PROFILE_DEV_DEBUG=0). To inspect locals in a debugger, build with +# CARGO_PROFILE_DEV_DEBUG=true. debug = "line-tables-only" # Test builds compile ui-egui without optimization: its library and unit tests were the biggest @@ -167,3 +179,20 @@ opt-level = "z" [profile.wasm-release.package.photocraft-paint] opt-level = 3 +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index a7302a940c..6250865673 100644 --- a/NOTICE +++ b/NOTICE @@ -22,3 +22,20 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts ship with the package as OFL-.txt. + +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. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Original: https://github.com/jslegers/emoji-icon-font +The following notice is reproduced verbatim from emoji-icon-font-mit-license.txt: + +MIT License + +Copyright (c) 2014 John Slegers + +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/README.md b/README.md index 538e34d047..a22f1aee26 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 67 tools

- Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 68 tools

+ Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

@@ -221,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files. -- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`). +- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`). - **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity. - **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively. @@ -343,7 +343,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi **New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it. -**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. +**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. ### Windows diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..829ff75af8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026 PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md) @@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo | Korean | `ko` | 100% | partial | | Vietnamese | `vi` | 0% | none | -Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). +Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). ## Upcoming @@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| +| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) | | 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. | | 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus | | 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar | @@ -135,7 +136,9 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | +| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) | +| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | +| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate | | 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) | | 2026-10-10 | minor | Readiness by audience with hours per tier | | 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added | diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index f4848f379a..94e04b518a 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -28,9 +28,19 @@ photocraft-engine = { workspace = true } photocraft-io = { workspace = true } photocraft-format = { path = "../../crates/format" } photocraft-codecs = { workspace = true } +clap = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +[dev-dependencies] +# Synthetic camera raws (#7789). +photocraft-raw = { workspace = true, features = ["testgen"] } +# Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in +# the binary. +clap_complete = { workspace = true } +clap_mangen = { workspace = true } + [build-dependencies] # Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows. winresource = "0.1" diff --git a/apps/photocraft-cli/examples/man-and-completions.rs b/apps/photocraft-cli/examples/man-and-completions.rs new file mode 100644 index 0000000000..ddb9cf0233 --- /dev/null +++ b/apps/photocraft-cli/examples/man-and-completions.rs @@ -0,0 +1,41 @@ +//! Writes photocraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish, +//! PowerShell) and man pages ([`clap_mangen`]: `photocraft-cli.1` and one per subcommand, such as +//! `photocraft-cli-convert.1`) into a directory. The Linux and FreeBSD packages ship them +//! (`packaging/linux/package.sh`, `packaging/freebsd/package.sh`): +//! +//! ```sh +//! cargo run -p photocraft-cli --example man-and-completions -- +//! ``` + +use std::path::Path; +use std::process::ExitCode; + +use clap::ValueEnum; +use clap_complete::Shell; + +fn main() -> ExitCode { + let mut args = std::env::args_os().skip(1); + let (Some(dir), None) = (args.next(), args.next()) else { + eprintln!("usage: man-and-completions "); + return ExitCode::from(2); + }; + let dir = Path::new(&dir); + match generate(dir) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {}: {e}", dir.display()); + ExitCode::FAILURE + } + } +} + +fn generate(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let mut cmd = photocraft_cli::command(); + for &shell in Shell::value_variants() { + clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", dir)?; + } + // A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand, + // which `clap_mangen` would otherwise give pages of its own (`photocraft-cli-help-convert.1`). + clap_mangen::generate_to(photocraft_cli::command(), dir) +} diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs new file mode 100644 index 0000000000..4d494c8a82 --- /dev/null +++ b/apps/photocraft-cli/src/actions.rs @@ -0,0 +1,116 @@ +use std::path::{Path, PathBuf}; + +use photocraft_engine::actions_cmds::Action; +use serde_json::{Value, json}; + +/// Why an actions file can't be played. +#[derive(Debug, thiserror::Error)] +pub enum ActionsError { + #[error("actions JSON: {0}")] + Json(#[source] serde_json::Error), + /// The steps don't parse ([`photocraft_engine::automate_cmds::parse_action`]). + #[error(transparent)] + Steps(#[from] photocraft_engine::EngineError), + #[error("--action picks an action from an Actions set ({{\"actions\": [{{\"name\", \"steps\"}}…]}}), but this file holds one action's steps")] + NotASet, + #[error("Actions set: {0}")] + Set(#[source] serde_json::Error), + #[error("no action named `{name}` in the set (it holds {})", quoted(names))] + NoSuchAction { name: String, names: Vec }, + #[error("the Actions set holds {} actions: pick one with --action ({})", names.len(), quoted(names))] + PickOne { names: Vec }, + #[error("{}: {source}", path.display())] + Read { path: PathBuf, source: std::io::Error }, + #[error("{}: {source}", path.display())] + Utf8 { path: PathBuf, source: std::string::FromUtf8Error }, + /// A Photoshop Actions file that doesn't import ([`photocraft_engine::atn_cmds::import`]). + #[error("{}: {message}", path.display())] + Atn { path: PathBuf, message: String }, + #[error("no action named `{name}` in the set `{set}` (it holds {})", quoted(names))] + NoSuchAtnAction { name: String, set: String, names: Vec }, + #[error("{}: the set holds no actions", path.display())] + EmptyAtn { path: PathBuf }, + #[error("the set `{set}` holds {} actions: pick one with --action ({})", names.len(), quoted(names))] + PickOneAtn { set: String, names: Vec }, +} + +/// `` `a`, `b` ``: the action names an error lists. +fn quoted(names: &[String]) -> String { + names.iter().map(|n| format!("`{n}`")).collect::>().join(", ") +} + +fn names(actions: &[Action]) -> Vec { + actions.iter().map(|a| a.name.clone()).collect() +} + +/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs, +/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a +/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489). +pub fn parse_actions(text: &str) -> Result, ActionsError> { + let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?; + steps_of(&v) +} + +fn steps_of(v: &Value) -> Result { + Ok(photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions")?) +} + +/// Engine commands to run in order: `(id, params)`. +pub type Steps = Vec<(String, Value)>; + +/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the +/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays +/// the action `name` picks (optional when the set holds one) with the whole set loaded, as +/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786). +pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec), ActionsError> { + let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?; + let list = v.get("actions").unwrap_or(&v); + let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string))); + if !is_set { + if name.is_some() { + return Err(ActionsError::NotASet); + } + return Ok((steps_of(&v)?, Vec::new())); + } + let set: Vec = serde_json::from_value(list.clone()).map_err(ActionsError::Set)?; + let action = match (name, set.as_slice()) { + (Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAction { name: n.to_owned(), names: names(&set) })?, + (None, [only]) => only, + (None, _) => return Err(ActionsError::PickOne { names: names(&set) }), + }; + let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; + Ok((play, set)) +} + +/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by +/// its version word) is converted first; the played action's steps with no PhotoCraft +/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run). +pub fn load_actions_file(path: &Path, name: Option<&str>) -> 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]); + 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)?; + return Ok((steps, set, Vec::new())); + } + let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?; + let action = match (name, imp.actions.as_slice()) { + (Some(n), _) => imp.actions.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAtnAction { + name: n.to_owned(), + set: imp.set.clone(), + names: names(&imp.actions), + })?, + (None, [only]) => only, + (None, []) => return Err(ActionsError::EmptyAtn { path: path.to_path_buf() }), + (None, _) => return Err(ActionsError::PickOneAtn { set: imp.set.clone(), names: names(&imp.actions) }), + }; + let warnings = imp + .unsupported + .iter() + .filter(|u| u.action == action.name) + .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) + .chain(imp.warnings.iter().cloned()) + .collect(); + let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; + Ok((play, imp.actions, warnings)) +} diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs new file mode 100644 index 0000000000..91b99ef33b --- /dev/null +++ b/apps/photocraft-cli/src/args.rs @@ -0,0 +1,428 @@ +//! The command line, as clap types. Values are parsed into their types here, so a bad one is a +//! usage error naming its flag (exit 2) and the handlers never see it (#7071). A flag a +//! subcommand doesn't take is a usage error too, so a typo such as `--fromat` can't be silently +//! ignored (#423), and so is a repeated one: `--quality 80 --quality 60` is more likely a mistake +//! than a choice. A value starting with `-` is written `--out=-image.png` (or `./-image.png`), so a +//! value flag never takes the next flag as its value; negative numbers are kept where they mean +//! something. + +use std::ffi::OsString; +use std::fmt; +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; + +use clap::error::ErrorKind; +use clap::parser::ValueSource; +use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; +use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars}; +use photocraft_codecs::{JpegFormat, TiffCompression}; +use photocraft_io::{DdsCompression, DpxBits, TgaBits}; +use serde_json::Value; + +/// photocraft-cli: headless PhotoCraft +#[derive(Parser)] +#[command(name = "photocraft-cli", version = photocraft_engine::build_info::long_version_static(), arg_required_else_help = true)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand)] +pub(crate) enum Command { + Convert(ConvertArgs), + Info(InfoArgs), + Run(RunArgs), + Batch(BatchArgs), + Droplet(DropletArgs), + Commands(CommandsArgs), + Mcp(McpArgs), + Serve(ServeArgs), +} + +// How an image is written: shared by `convert`, `run` and `batch`. +#[derive(Args)] +pub(crate) struct ExportArgs { + /// Output format, overriding the output's extension (png, jpg, tif, webp, …) + #[arg(long, value_name = "EXT")] + pub(crate) format: Option, + /// JPEG or WebP quality, 1-100. A WebP written with a quality is lossy, without one lossless + // allow_negative_numbers makes `--quality -1` an out-of-range value rather than an unexpected argument. + #[arg(long, value_name = "1-100", value_parser = clap::value_parser!(u8).range(1..=100), allow_negative_numbers = true)] + pub(crate) quality: Option, + /// Keep the layers in TIFF output (Photoshop layer data); TIFF output is flat without it + #[arg(long)] + pub(crate) tiff_layers: bool, + /// 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, + /// 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)] + pub(crate) tga_bits: Option, + /// A DPX's bits per sample. Without it: 8 for an 8-bit document, else 16 + #[arg(long, value_name = "8|10|12|16", value_parser = dpx_bits)] + pub(crate) dpx_bits: Option, + /// A DDS texture's compression: none (uncompressed, the default), bc1 (DXT1: alpha on or + /// off), bc2 (DXT3) or bc3 (DXT5) + #[arg(long, value_name = "none|bc1|bc2|bc3", value_parser = dds_compression)] + pub(crate) dds_compression: Option, + /// Also write a DDS texture's full mip chain + #[arg(long)] + pub(crate) dds_mipmaps: bool, + /// A JPEG's scans, as JPEG Options picks them: baseline (the default), optimized (Huffman + /// tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so only Gray + /// and CMYK shrink) or progressive + #[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)] + pub(crate) jpeg_format: Option, +} + +/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) +/// +/// An that is the file is refused, as the result would replace the original; +/// --in-place allows it. +#[derive(Args)] +pub(crate) struct ConvertArgs { + /// The file to read + #[arg(value_name = "IN")] + pub(crate) input: PathBuf, + /// The file to write; its extension picks the format unless --format is given + #[arg(value_name = "OUT")] + pub(crate) output: PathBuf, + /// Allow an that is the file, replacing the original + #[arg(long)] + pub(crate) in_place: bool, + #[command(flatten)] + pub(crate) export: ExportArgs, +} + +/// Print the document as JSON (size, mode, depth, layer tree) +#[derive(Args)] +pub(crate) struct InfoArgs { + pub(crate) file: PathBuf, + /// One line of JSON instead of pretty-printed + #[arg(long)] + pub(crate) compact: bool, +} + +/// Open a file (or start a new document), run engine commands in order, save the result +/// +/// Prints each command's JSON result. Give exactly one of or --new, and at least one --cmd +/// or --out. An --out that is the is refused, as the result would replace the original; +/// --in-place allows it. +#[derive(Args)] +#[command(group(ArgGroup::new("source").args(["file", "new"]).required(true)))] +#[command(group(ArgGroup::new("work").args(["cmd", "out"]).required(true).multiple(true)))] +pub(crate) struct RunArgs { + /// The document to open + pub(crate) file: Option, + /// Start from a new document with these JSON parameters instead of opening a file + #[arg(long, value_name = "JSON", value_parser = json)] + pub(crate) new: Option, + /// An engine command to run; repeat for several, run in order + #[arg(long, value_name = "ID", action = ArgAction::Append)] + pub(crate) cmd: Vec, + /// JSON parameters for the --cmd before it (default {}) + #[arg(long, value_name = "JSON", action = ArgAction::Append, value_parser = json)] + pub(crate) params: Vec, + /// Save the result to this file + #[arg(long, value_name = "FILE")] + pub(crate) out: Option, + /// Allow an --out that is the , replacing the original + #[arg(long)] + pub(crate) in_place: bool, + #[command(flatten)] + pub(crate) export: ExportArgs, + /// `--cmd` and `--params` values in command-line order, set by [`parse`]. + #[arg(skip)] + pub(crate) steps: Vec, +} + +/// One `--cmd` or `--params` of `run`, in command-line order. +pub(crate) enum Step { + Cmd(String), + Params(Value), +} + +/// Apply an action list to every image in a directory +/// +/// A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps +/// are [id, params] pairs, {"command": id, "params": {…}} objects or bare ids, as a recorded +/// action or droplet stores them (a list, or wrapped in {"actions": …}, {"steps": …} or a +/// droplet). An Actions set (the Actions panel's actions.json, {"actions": [{"name", "steps"}…]}) +/// plays the action --action names (needed when the set holds more than one) with the whole set +/// loaded, so its steps can play other actions of the set. +#[derive(Args)] +pub(crate) struct BatchArgs { + /// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn) + #[arg(long, value_name = "FILE")] + pub(crate) actions: PathBuf, + /// Which action of an Actions set to play + #[arg(long, value_name = "NAME")] + pub(crate) action: Option, + /// The folder of images to process + #[arg(long = "in", value_name = "DIR")] + pub(crate) in_dir: PathBuf, + /// The folder to write the results to, as . + #[arg(long, value_name = "DIR")] + pub(crate) out: PathBuf, + /// An --out folder that is the --in folder is refused, as the results would replace the + /// originals; --in-place allows it + #[arg(long)] + pub(crate) in_place: bool, + #[command(flatten)] + pub(crate) export: ExportArgs, +} + +/// Run a droplet (File › Automate › Create Droplet) on images and folders +#[derive(Args)] +pub(crate) struct DropletArgs { + /// The droplet to run + #[arg(value_name = "FILE.pcdroplet")] + pub(crate) file: String, + /// Images and folders to run it on + #[arg(value_name = "FILE-OR-DIR", required = true)] + pub(crate) inputs: Vec, + /// The folder to write the results to + #[arg(long, value_name = "DIR")] + pub(crate) out: Option, +} + +/// List the engine command registry +#[derive(Args)] +pub(crate) struct CommandsArgs { + /// Print the entries as JSON + #[arg(long)] + pub(crate) json: bool, + /// Only the commands matching this text + #[arg(long, value_name = "TEXT", allow_negative_numbers = true)] + pub(crate) filter: Option, +} + +/// Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control `) +/// +/// Headless: each automation root grants that direction of file access; an omitted root denies it. +/// Prefer absolute root paths in MCP client configurations. Bridge: set the roots on the desktop +/// app; root flags are refused. The token flags require --bridge (or PHOTOCRAFT_CONTROL_PORT). +#[derive(Args)] +pub(crate) struct McpArgs { + /// Bridge to the PhotoCraft app listening here instead of a headless engine; a bare port means + /// 127.0.0.1: + #[arg(long, value_name = "127.0.0.1:PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = bridge)] + pub(crate) bridge: Option, + /// `--bridge` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`]. + #[arg(skip)] + pub(crate) bridge_from_env: bool, + #[command(flatten)] + pub(crate) control: ControlArgs, + #[command(flatten)] + pub(crate) automation: AutomationArgs, +} + +/// Keep one headless session open and answer JSON lines on stdio, or on 127.0.0.1: +/// +/// Each request is a JSON line {"id", "method", "params"}. Methods: engine.execute, +/// jobs.list/cancel, engine.commands, +/// doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, +/// session.list, batch, methods (docs/control-protocol.md#headless-server). The token flags require +/// --port (or PHOTOCRAFT_CONTROL_PORT); stdio needs no control token. +#[derive(Args)] +pub(crate) struct ServeArgs { + /// Listen on 127.0.0.1: instead of stdio + #[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port)] + pub(crate) port: Option, + /// `--port` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`]. + #[arg(skip)] + pub(crate) port_from_env: bool, + #[command(flatten)] + pub(crate) control: ControlArgs, + #[command(flatten)] + pub(crate) automation: AutomationArgs, +} + +/// Why a value was refused: clap prints it after the flag and its value (exit 2). +#[derive(Debug, thiserror::Error)] +enum ValueError { + #[error("expected 24 or 32")] + TgaBits, + #[error("expected 8, 10, 12 or 16")] + DpxBits, + #[error("expected none, bc1, bc2 or bc3")] + DdsCompression, + #[error("expected none, lzw or zip")] + TiffCompression, + #[error("expected baseline, optimized or progressive")] + JpegFormat, + /// 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, +} + +/// Why `parse` refuses `mcp` or `serve` options clap accepted: clap prints it as a usage error +/// (exit 2). +#[derive(Debug, thiserror::Error)] +enum ServerOptionError { + /// PHOTOCRAFT_CONTROL_PORT holds a port, as for the desktop app's --control: only the flag + /// takes host:port. + #[error("invalid value '{0}' for 'PHOTOCRAFT_CONTROL_PORT': {bridge}", bridge = ValueError::Bridge)] + BridgeVar(String), + /// With --bridge the roots are the desktop process's to set: accepting them here promises + /// authority the bridge can't give (#8035). + #[error("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode")] + BridgeRoots, + /// A token flag without the bridge or port that reads it would be ignored (#8035). + #[error("{flag} requires {transport}")] + NeedsTransport { flag: &'static str, transport: &'static str }, +} + +impl ServerOptionError { + fn into_clap(self, cmd: &mut clap::Command) -> clap::Error { + let kind = match self { + Self::BridgeVar(_) => ErrorKind::ValueValidation, + Self::BridgeRoots => ErrorKind::ArgumentConflict, + Self::NeedsTransport { .. } => ErrorKind::MissingRequiredArgument, + }; + cmd.error(kind, self) + } +} + +fn tga_bits(s: &str) -> Result { + s.parse().ok().and_then(TgaBits::from_bits).ok_or(ValueError::TgaBits) +} + +fn dpx_bits(s: &str) -> Result { + s.parse().ok().and_then(DpxBits::from_bits).ok_or(ValueError::DpxBits) +} + +fn dds_compression(s: &str) -> Result { + DdsCompression::from_name(s).ok_or(ValueError::DdsCompression) +} + +fn tiff_compression(s: &str) -> Result { + match s { + "none" => Ok(TiffCompression::None), + "lzw" => Ok(TiffCompression::Lzw), + "zip" => Ok(TiffCompression::Deflate), + _ => Err(ValueError::TiffCompression), + } +} + +fn jpeg_format(s: &str) -> Result { + JpegFormat::parse(s).ok_or(ValueError::JpegFormat) +} + +/// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:` for a port. +#[derive(Clone)] +pub(crate) enum Bridge { + /// A bare port: the app on 127.0.0.1. + Port(u16), + /// `host:port`, as given. Whether the host is loopback is the bridge's to say. + Addr(String), +} + +impl fmt::Display for Bridge { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Bridge::Port(port) => SocketAddr::from((Ipv4Addr::LOCALHOST, *port)).fmt(f), + Bridge::Addr(addr) => f.write_str(addr), + } + } +} + +/// `mcp --bridge`, or PHOTOCRAFT_CONTROL_PORT, which holds a port. +fn bridge(s: &str) -> Result { + match control_port(s) { + Ok(port) => Ok(Bridge::Port(port)), + Err(_) if s.contains(':') => Ok(Bridge::Addr(s.to_owned())), + Err(_) => Err(ValueError::Bridge), + } +} + +/// `--new` and `--params`: bad JSON is a usage error, before any work. Whether the value is an +/// object is the command's to say. +fn json(s: &str) -> Result { + serde_json::from_str(s) +} + +/// `--cmd` and `--params` values back in command-line order: clap keeps each flag's values apart, +/// their indices say how they interleave. +fn steps(m: &ArgMatches) -> Vec { + let mut steps: Vec<(usize, Step)> = Vec::new(); + if let (Some(at), Ok(Some(ids))) = (m.indices_of("cmd"), m.try_get_many::("cmd")) { + steps.extend(at.zip(ids).map(|(i, id)| (i, Step::Cmd(id.clone())))); + } + if let (Some(at), Ok(Some(params))) = (m.indices_of("params"), m.try_get_many::("params")) { + steps.extend(at.zip(params).map(|(i, p)| (i, Step::Params(p.clone())))); + } + steps.sort_by_key(|s| s.0); + steps.into_iter().map(|(_, s)| s).collect() +} + +/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not +/// be valid Unicode (#1108), text must. A flag left out falls back to its environment variable, +/// unless that is empty, and a bad value from one is a usage error naming the variable (#2491). A +/// bare `--help` or `-h` is help even after a mistake on the same line (`--out --help`, `mcp +/// stray.psd --help`): clap reports the mistake first. +pub(crate) fn parse(args: &[OsString]) -> Result { + let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset)); + let argv = std::iter::once(OsString::from("photocraft-cli")).chain(args.iter().cloned()); + let matches = match cmd.try_get_matches_from_mut(argv) { + Err(e) if args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => { + let help = [OsString::from("photocraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")]; + return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e)); + } + matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?, + }; + let mut cli = Cli::from_arg_matches(&matches)?; + let from_env = |m: &ArgMatches, id: &str| m.value_source(id) == Some(ValueSource::EnvVariable); + match (&mut cli.command, matches.subcommand()) { + (Command::Run(r), Some((_, m))) => r.steps = steps(m), + (Command::Mcp(a), Some((name, m))) => { + a.bridge_from_env = from_env(m, "bridge"); + if a.bridge_from_env + && let Some(Bridge::Addr(v)) = &a.bridge + { + return Err(ServerOptionError::BridgeVar(v.clone()).into_clap(&mut cmd)); + } + if let Some(sub) = cmd.find_subcommand_mut(name) { + // Only the bridge reads a token, so a stale exported pair doesn't stop a headless + // run. + if a.bridge.is_some() { + conflict(sub, m, "control_token", "control_token_file")?; + } + // The roots are the headless engine's: the variable switching to the bridge would + // drop them, and the error names it. + if a.bridge_from_env { + conflict(sub, m, "automation_read_root", "bridge")?; + conflict(sub, m, "automation_write_root", "bridge")?; + } + if a.bridge.is_some() && (a.automation.automation_read_root.is_some() || a.automation.automation_write_root.is_some()) { + return Err(ServerOptionError::BridgeRoots.into_clap(sub)); + } + check_server(sub, m, a.bridge.is_some(), "--bridge")?; + } + } + (Command::Serve(a), Some((name, m))) => { + a.port_from_env = from_env(m, "port"); + if let Some(sub) = cmd.find_subcommand_mut(name) { + // Only the TCP server reads a token, so a stale exported pair doesn't stop stdio. + if a.port.is_some() { + conflict(sub, m, "control_token", "control_token_file")?; + } + check_server(sub, m, a.port.is_some(), "--port")?; + } + } + _ => {} + } + Ok(cli) +} + +/// Refuse a token flag without the bridge or port that reads it (as a flag or its variable). A +/// token variable alone may be a stale export, so it isn't refused. +fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, transport: &'static str) -> Result<(), clap::Error> { + for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] { + if m.value_source(id) == Some(ValueSource::CommandLine) && !has_transport { + return Err(ServerOptionError::NeedsTransport { flag, transport }.into_clap(sub)); + } + } + Ok(()) +} diff --git a/apps/photocraft-cli/src/automation.rs b/apps/photocraft-cli/src/automation.rs new file mode 100644 index 0000000000..062f2f2eef --- /dev/null +++ b/apps/photocraft-cli/src/automation.rs @@ -0,0 +1,82 @@ +use std::io::Write; +use std::net::{Ipv4Addr, SocketAddr}; + +use serde_json::Value; + +use photocraft_automation::{Headless, PhotocraftMcp, security}; + +use crate::{ + Result, + args::{CommandsArgs, McpArgs, ServeArgs}, + output::{JsonStyle, print_json}, +}; + +pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result { + let h = Headless::new(); + let list = h.command_list(); + let needle = a.filter.as_deref().map(str::to_lowercase); + let items: Vec<&Value> = list + .as_array() + .map(|v| v.iter().collect::>()) + .unwrap_or_default() + .into_iter() + .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) + .collect(); + if a.json { + return print_json(out, &Value::Array(items.into_iter().cloned().collect()), JsonStyle::Pretty); + } + for c in items { + let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::>().join(" > ")).unwrap_or_default(); + writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu)?; + } + Ok(()) +} + +pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result { + use std::sync::{Arc, Mutex}; + let h = Arc::new(Mutex::new(Headless::with_workspace(a.automation.workspace()?))); + match a.port { + Some(port) => { + if a.port_from_env { + // Name the mode switch: an exported variable changes what this command does, and + // unsetting it (or setting it empty) is the way back to stdio. + let _ = writeln!(err, "photocraft-cli: PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio (unset it to serve stdio)"); + } + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string(); + let (supplied, token_file) = (a.control.control_token.as_deref(), a.control.control_token_file.as_deref()); + let token = security::server_token(supplied, token_file)?; + if let Some(path) = token_file { + let _ = writeln!(err, "photocraft-cli control token file: {}", path.display()); + } else if supplied.is_none() { + let _ = writeln!(err, "photocraft-cli control token: {token}"); + } + photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| { + let _ = writeln!(err, "photocraft-cli serving on {local}"); + })?; + Ok(()) + } + None => { + let stdin = std::io::stdin(); + Ok(photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout())?) + } + } +} + +pub(crate) fn mcp(a: &McpArgs, err: &mut dyn Write) -> Result { + let server = match &a.bridge { + Some(addr) => { + if a.bridge_from_env { + // Name the mode switch, as `serve` does: the way back to headless is to unset it. + let _ = writeln!( + err, + "photocraft-cli: PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on {addr} instead of a headless engine (unset it to run headless)" + ); + } + let token = security::client_token(a.control.control_token.as_deref(), a.control.control_token_file.as_deref())?; + PhotocraftMcp::bridge(&addr.to_string(), &token)? + } + None => PhotocraftMcp::headless_with_workspace(a.automation.workspace()?), + }; + let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; + Ok(rt.block_on(server.serve_stdio())?) +} diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs new file mode 100644 index 0000000000..bdaf249bc9 --- /dev/null +++ b/apps/photocraft-cli/src/batch.rs @@ -0,0 +1,210 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; + +use photocraft_automation::Headless; +use photocraft_engine::actions_cmds::Action; +use photocraft_io::ExportOptions; +use serde_json::json; + +use crate::{ + CliError, Result, + actions::load_actions_file, + args::{BatchArgs, DropletArgs}, + output::warn_all, +}; + +/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a +/// trailing separator, through a symbolic link). +fn same_dir(a: &Path, b: &Path) -> bool { + matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b) +} + +pub(crate) fn is_input(p: &Path) -> bool { + let known = |e: &str| { + matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub") + || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) + }; + p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) +} + +/// Drops the Save and view steps the desktop records from `action` (Batch saves each result +/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860). +fn strip_shell_steps(action: &mut Action) { + use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command}; + let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new()); + for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() { + if shell_save_command(&id) || shell_view_command(&id) { + continue; + } + if action.disabled.contains(&i) { + disabled.insert(steps.len()); + } + if action.dialogs.contains(&i) { + dialogs.insert(steps.len()); + } + steps.push((id, p)); + } + (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); +} + +pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { + let (in_dir, out_dir) = (a.in_dir.as_path(), a.out.as_path()); + let (actions, mut set, warnings) = load_actions_file(&a.actions, a.action.as_deref())?; + set.iter_mut().for_each(strip_shell_steps); + warn_all(err, &warnings); + let opts = ExportOptions::from(&a.export); + // Results are saved as `/.`, so an `--out` that is the `--in` folder would + // replace the originals (#492). + 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 (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 r = (|| -> Result> { + written.check(&target_text).map_err(CliError::OutputClaimed)?; + 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)?; + for (id, p) in &actions { + if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) { + continue; + } + let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?; + // A failed step inside a called action makes this file an error, not a saved result. + if let Some(message) = photocraft_engine::actions_cmds::nested_failure(id, &r) { + return Err(CliError::NestedFailure { id: id.clone(), message }); + } + } + let r = h.save(None, Some(&target), Some(&ext), &opts)?; + Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default()) + })(); + 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()); + } + } + } + let _ = writeln!(out, "{ok} succeeded, {failed} failed"); + if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) } +} + +pub(crate) fn droplet(a: &DropletArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { + let (file, inputs) = (&a.file, &a.inputs); + let mut p = json!({"droplet": file, "input": inputs}); + if let Some(o) = &a.out { + p["output"] = json!(o); + } + let mut s = photocraft_engine::Session::new(); + let r = s.execute("file.automate.runDroplet", p)?; + for f in r["files"].as_array().into_iter().flatten() { + let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default()); + } + let errors = r["errors"].as_array().cloned().unwrap_or_default(); + for e in &errors { + let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default()); + } + if errors.is_empty() { Ok(()) } else { Err(CliError::FilesFailed(errors.len())) } +} + +#[cfg(test)] +mod batch_action_set_tests { + use super::*; + use crate::test_support::{Invoke, invoke}; + use serde_json::Value; + + /// A batched action that plays another action of its set finds it in every file's session; a + /// failure inside the called action fails the file, and a self-call stops instead of looping. + #[test] + fn batch_plays_an_action_that_calls_another_action_of_its_set() { + let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + for name in ["a", "b"] { + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]); + assert_eq!(code, 0, "{stderr}"); + } + let set = json!({"version": 1, "actions": [ + {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]}, + {"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]}, + {"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]}, + {"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]}, + {"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]}, + ]}); + std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); + let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); + + let Invoke { code, stdout, stderr } = batch("Outer"); + assert_eq!(code, 0, "{stdout}{stderr}"); + for name in ["a", "b"] { + let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action"); + } + for action in ["Calls bad", "Loop"] { + let Invoke { code, stdout, stderr } = batch(action); + assert_ne!(code, 0, "{stdout}"); + assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}"); + assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// Save and view steps recorded inside a batched action set are skipped, also in a called + /// action, and unchecked steps stay unchecked once they are gone (#6860). + #[test] + fn batch_skips_save_and_view_steps_inside_an_action_set() { + let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); + assert_eq!(code, 0, "{stderr}"); + let set = json!({"version": 1, "actions": [ + {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]}, + {"name": "Rotate", "steps": [ + ["file.save", {}], + ["image.imageRotation.90cw", {}], + ["view.fitOnScreen", {}], + ["image.imageRotation.90cw", {}], + ["file.saveAs", {}], + ], "disabled": [3], "dialogs": [1]}, + ]}); + std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); + for action in ["Outer", "Rotate"] { + let Invoke { code, stdout, stderr } = + invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); + assert_eq!(code, 0, "{stdout}{stderr}"); + let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped"); + } + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs new file mode 100644 index 0000000000..9d730b4635 --- /dev/null +++ b/apps/photocraft-cli/src/document.rs @@ -0,0 +1,226 @@ +use std::io::Write; +use std::path::Path; + +use photocraft_automation::{Headless, files}; +use serde_json::{Value, json}; + +use photocraft_engine::type_extra_cmds::missing_font_warnings; +use photocraft_io::ExportOptions; + +use crate::{ + CliError, Result, + args::{ConvertArgs, InfoArgs, RunArgs, Step}, + output::{JsonStyle, print_json, warn_all}, +}; + +/// An output that is the input file itself, however it is spelt (relative, with `./`, through a +/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it +/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in` +/// folder (#7437). The paths are compared when the command starts and the save opens `output` +/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards +/// against mistakes, it is not a security boundary. +fn refuse_overwriting_input(in_place: bool, input: &Path, output: &Path) -> Result { + if !in_place && photocraft_engine::file_cmds::same_file(input, output) { + return Err(CliError::OutIsInput(output.to_path_buf())); + } + Ok(()) +} + +pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result { + let (input, output) = (&a.input, &a.output); + let opts = ExportOptions::from(&a.export); + refuse_overwriting_input(a.in_place, input, output)?; + let mut o = files::open(input)?; + // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. + photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); + warn_all(err, &o.warnings); + warn_all(err, &missing_font_warnings(&o.document)); + let ws = files::save(&o.document, output, a.export.format.as_deref(), &opts, None)?; + warn_all(err, &ws); + Ok(()) +} + +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 mut doc = h.inspect(None)?; + let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); + if let Some(st) = h.session.active() { + warnings.extend(missing_font_warnings(&st.doc).into_iter().map(Value::String)); + } + doc["warnings"] = Value::Array(warnings); + doc["file"] = json!(file.to_string_lossy()); + // Drop per-session noise. + if let Value::Object(m) = &mut doc { + for k in ["history", "canUndo", "canRedo", "revision"] { + m.remove(k); + } + } + print_json(out, &doc, if a.compact { JsonStyle::Compact } else { JsonStyle::Pretty }) +} + +/// `(id, params)` pairs from repeated `--cmd`/`--params` flags. +fn command_list(a: &RunArgs) -> Result> { + let mut cmds: Vec<(String, Value)> = Vec::new(); + for step in &a.steps { + match step { + Step::Cmd(id) => cmds.push((id.clone(), json!({}))), + Step::Params(p) => cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?.1 = p.clone(), + } + } + Ok(cmds) +} + +pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result { + let opts = ExportOptions::from(&a.export); + let mut h = Headless::trusted_local(); + // Opening, running commands and exporting can all notice the same missing font: + // one `run` invocation reports each warning line at most once. + let mut reported_fonts = std::collections::HashSet::::new(); + // clap's `source` group gives exactly one of the two, and its `work` group a --cmd or --out. + if let Some(new) = &a.new { + h.command_run("file.new", new.clone()).map_err(CliError::New)?; + } else if let Some(file) = &a.file { + // Refused before the file is read or any command runs. + if let Some(out) = &a.out { + refuse_overwriting_input(a.in_place, file, out)?; + } + let o = h.open(file)?; + 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 { + 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 }); + } + let new_warnings: Vec = + h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); + warn_all(err, &new_warnings); + let mut result = json!({"command": id, "result": r}); + if !new_warnings.is_empty() { + result["warnings"] = json!(new_warnings); + } + print_json(out, &result, JsonStyle::Compact)?; + } + // A `run` with only `--out` still reports missing fonts before exporting. + let remaining: Vec = + h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); + warn_all(err, &remaining); + if let Some(o) = a.out.as_deref() { + let r = h.save(None, Some(o), a.export.format.as_deref(), &opts)?; + let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); + let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); + warn_all(err, &ws); + } + Ok(()) +} + +#[cfg(test)] +mod missing_font_warning_tests { + use super::*; + use crate::{ + batch::is_input, + test_support::{Invoke, invoke}, + }; + + #[test] + fn missing_typeface_is_reported_by_run_info_and_convert() { + let family = "DefinitelyMissingPhotoCraftTypeface1251"; + let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let native = folder.join("missing.pcraft"); + let png = folder.join("missing.png"); + let native_str = native.to_string_lossy().to_string(); + let png_str = png.to_string_lossy().to_string(); + let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string(); + + let Invoke { code, stdout, stderr } = invoke(&[ + "run", + "--new", + r#"{"width":256,"height":96,"background":"white"}"#, + "--cmd", + "type.create", + "--params", + ¶ms, + "--cmd", + "type.resolveMissingFonts", + "--out", + &native_str, + ]); + assert_eq!(code, 0, "{stderr}"); + assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning"); + let commands: Vec = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect(); + assert_eq!(commands.len(), 2, "{stdout}"); + assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); + assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported"); + + let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); + assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr"); + + let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]); + assert_eq!(code, 0, "{stderr}"); + assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}"); + assert!(png.is_file()); + + // Even with no commands, run --out checks the opened document before export. + let Invoke { code, stdout, stderr } = invoke(&["run", &native_str, "--out", &png_str]); + assert_eq!(code, 0, "{stderr}"); + assert!(stdout.is_empty()); + assert_eq!(stderr.matches(family).count(), 1); + + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn convert_reads_and_writes_openraster() { + let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let path = |name: &str| folder.join(name).to_string_lossy().to_string(); + let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png")); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]); + assert_eq!(code, 0, "{stderr}"); + let Invoke { code, stderr, .. } = invoke(&["convert", &native, &ora]); + assert_eq!(code, 0, "{stderr}"); + let Invoke { code, stdout, stderr } = invoke(&["info", &ora, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}"); + let Invoke { code, stderr, .. } = invoke(&["convert", &ora, &png]); + assert_eq!(code, 0, "{stderr}"); + assert!(is_input(std::path::Path::new(&ora))); + // Krita documents are batch inputs too (import-only). + for name in ["sketch.kra", "old.KRZ"] { + std::fs::write(folder.join(name), b"").unwrap(); + assert!(is_input(&folder.join(name)), "{name}"); + } + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn no_type_layers_do_not_produce_missing_font_warnings() { + let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let native = folder.join("blank.pcraft"); + let png = folder.join("blank.png"); + let native_str = native.to_string_lossy().to_string(); + let png_str = png.to_string_lossy().to_string(); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stderr.contains("font '"), "{stderr}"); + let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '"))); + let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stderr.contains("font '"), "{stderr}"); + let _ = std::fs::remove_dir_all(&folder); + } +} diff --git a/apps/photocraft-cli/src/error.rs b/apps/photocraft-cli/src/error.rs new file mode 100644 index 0000000000..3e4aa3caef --- /dev/null +++ b/apps/photocraft-cli/src/error.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use photocraft_automation::AutomationError; + +use crate::actions::ActionsError; + +/// Why a subcommand failed: printed as `error: {e}`, exit 1. +#[derive(Debug, thiserror::Error)] +pub(crate) enum CliError { + #[error(transparent)] + Automation(#[from] AutomationError), + #[error(transparent)] + Engine(#[from] photocraft_engine::EngineError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Actions(#[from] ActionsError), + #[error("{}: {source}", path.display())] + ReadDir { path: PathBuf, source: std::io::Error }, + #[error("--params must follow a --cmd")] + ParamsBeforeCmd, + #[error("--new: {0}")] + New(#[source] AutomationError), + #[error("`{id}`: {source}")] + Command { id: String, source: AutomationError }, + /// A step inside an action the command played failed ([`photocraft_engine::actions_cmds::nested_failure`]). + #[error("`{id}`: {message}")] + NestedFailure { id: String, message: String }, + #[error("{} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())] + 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("{0}")] + OutputClaimed(String), + #[error("{0} file(s) failed")] + FilesFailed(usize), +} + +pub(crate) type Result = std::result::Result; diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 076f0a1e98..c431b5529f 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -3,204 +3,63 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::ffi::OsString; use std::io::Write; -use std::path::{Path, PathBuf}; -use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security}; -use photocraft_engine::actions_cmds::Action; -use photocraft_io::ExportOptions; -use serde_json::{Value, json}; +use clap::CommandFactory; -pub const USAGE: &str = "\ -photocraft-cli: headless PhotoCraft +mod actions; +mod args; +mod automation; +mod batch; +mod document; +mod error; +mod output; -USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] - Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). - TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). - --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. - --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as - its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. - --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. - --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1 - (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. - photocraft-cli info [--compact] - Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] - Open a file, run engine commands in order, save the result. Each --params - applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] - Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a - set: its action --action names plays, as below. JSON steps are [id, params] pairs, - {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them - (a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions - panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed - when the set holds more than one) with the whole set loaded, so its steps can play other actions - of the set. An --out folder that is the --in folder is refused, as the results would replace - the originals; --in-place allows it. - photocraft-cli droplet … [--out ] - Run a droplet (File › Automate › Create Droplet) on images and folders. - photocraft-cli commands [--json] [--filter ] - List the engine command registry. - photocraft-cli mcp [--bridge <127.0.0.1:port>] [--control-token <64-hex> | --control-token-file ] - [--automation-read-root ] [--automation-write-root ] - Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control `). - photocraft-cli serve [--port ] [--control-token <64-hex> | --control-token-file ] - [--automation-read-root ] [--automation-write-root ] - Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio, - or on 127.0.0.1:. Methods: engine.execute, jobs.list/cancel, engine.commands, - doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods - (docs/control-protocol.md#headless-server). +pub use actions::{ActionsError, Steps, load_actions, load_actions_file, parse_actions}; +use args::{Command, parse}; +use automation::{commands, mcp, serve}; +use batch::{batch, droplet}; +use document::{convert, info, run_cmds}; +pub(crate) use error::{CliError, Result}; - photocraft-cli --help (or -h) prints this text. A flag the subcommand doesn't take is - an error. -"; - -struct Args { - positional: Vec, - flags: Vec<(String, Option)>, +/// The command line's clap definition, for generating shell completions and man pages +/// (`examples/man-and-completions.rs`). +pub fn command() -> clap::Command { + args::Cli::command() } -/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag -/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423). -struct Subcommand { - name: &'static str, - values: &'static [&'static str], - bare: &'static [&'static str], - run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R, -} - -const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { - name: "convert", - values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], - bare: &["--tiff-layers", "--dds-mipmaps"], - run: convert, - }, - Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, - Subcommand { - name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], - bare: &["--tiff-layers", "--dds-mipmaps"], - run: run_cmds, - }, - Subcommand { - name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], - bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], - run: batch, - }, - Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet }, - Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) }, - Subcommand { - name: "mcp", - values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"], - bare: &[], - run: |a, _, _| mcp(a), - }, - Subcommand { - name: "serve", - values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"], - bare: &[], - run: |a, _, err| serve(a, err), - }, -]; - -/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`). -fn parse(sub: &Subcommand, args: &[String]) -> Result, String> { - let mut a = Args { positional: Vec::new(), flags: Vec::new() }; - let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name); - let mut i = 0; - while let Some(s) = args.get(i) { - if s == "--help" || s == "-h" { - return Ok(None); - } - if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) { - if sub.bare.contains(&k) { - return Err(format!("{k} takes no value")); - } - if !sub.values.contains(&k) { - return Err(unknown(k)); - } - a.flags.push((k.to_owned(), Some(v.to_owned()))); - } else if sub.values.contains(&s.as_str()) { - let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?; - a.flags.push((s.clone(), Some(v.clone()))); - i += 1; - } else if sub.bare.contains(&s.as_str()) { - a.flags.push((s.clone(), None)); - } else if s.starts_with("--") { - return Err(unknown(s)); - } else { - a.positional.push(s.clone()); - } - i += 1; - } - Ok(Some(a)) -} - -impl Args { - fn get(&self, k: &str) -> Option<&str> { - self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref()) - } - fn has(&self, k: &str) -> bool { - self.flags.iter().any(|(f, _)| f == k) - } -} - -type R = Result<(), String>; - -/// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error -/// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts -/// the process on one (issue #1108). -pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Write) -> i32 { - let mut strings = Vec::with_capacity(args.len()); - for (i, a) in args.iter().enumerate() { - match a.clone().into_string() { - Ok(s) => strings.push(s), - Err(raw) => { - let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{USAGE}", i + 1, raw.to_string_lossy()); - return 2; - } - } - } - run(&strings, out, err) -} - -/// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage). -pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { - let Some(cmd) = args.first() else { - let _ = write!(err, "{USAGE}"); - return 2; - }; - match cmd.as_str() { - "-h" | "--help" | "help" => { - let _ = write!(out, "{USAGE}"); - return 0; - } - "--version" | "version" => { - let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version()); - return 0; - } - _ => {} - } - let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else { - let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}"); - return 2; - }; - let parsed = match parse(sub, &args[1..]) { - Ok(Some(p)) => p, - // ` --help`: usage, without reading inputs or starting a server. - Ok(None) => { - let _ = write!(out, "{USAGE}"); - return 0; - } +/// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok, +/// 1 failure, 2 usage). clap reads the arguments as they are: a path that is not valid Unicode (a +/// Latin-1 file name on Linux) is taken, and one where text is expected is a usage error (exit 2). +/// Never a panic, as `std::env::args()` aborts on one (issue #1108). +pub fn run(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32 +where + I: IntoIterator, + T: Into, +{ + let args: Vec = args.into_iter().map(Into::into).collect(); + // Help and `--version` (`help`, `-h`, ` --help`, `-V`) come back as errors too, + // for `out`, exit 0: the subcommand never runs. + let cli = match parse(&args) { + Ok(cli) => cli, Err(e) => { - let _ = writeln!(err, "error: {e}\n\n{USAGE}"); - return 2; + let to: &mut dyn Write = if e.use_stderr() { err } else { out }; + let _ = write!(to, "{}", e.render()); + return e.exit_code(); } }; - match (sub.run)(&parsed, out, err) { + let result = match &cli.command { + Command::Convert(a) => convert(a, out, err), + Command::Info(a) => info(a, out), + Command::Run(a) => run_cmds(a, out, err), + Command::Batch(a) => batch(a, out, err), + Command::Droplet(a) => droplet(a, out, err), + Command::Commands(a) => commands(a, out), + Command::Mcp(a) => mcp(a, err), + Command::Serve(a) => serve(a, err), + }; + match result { Ok(()) => 0, Err(e) => { let _ = writeln!(err, "error: {e}"); @@ -209,612 +68,18 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { } } -fn export_opts(a: &Args) -> Result { - // TIFF output is flat unless asked: scripted conversions keep predictable sizes. - let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() }; - if let Some(q) = a.get("--quality") { - let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?; - o.encode.jpeg_quality = q; - // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. - o.encode.webp_quality = q; - o.encode.webp_lossless = false; - } - if let Some(b) = a.get("--tga-bits") { - o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?); - } - if let Some(b) = a.get("--dpx-bits") { - o.encode.dpx_bits = - Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); - } - if let Some(c) = a.get("--dds-compression") { - o.encode.dds.compression = - photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; - } - o.encode.dds.mipmaps = a.has("--dds-mipmaps"); - Ok(o) -} - -fn warn_all(err: &mut dyn Write, ws: &[String]) { - for w in ws { - let _ = writeln!(err, "warning: {w}"); - } -} - -/// The engine knows which type families aren't installed. Warn before an export -/// uses a fallback face rather than silently changing the appearance of the text. -fn missing_font_warnings(fonts: Vec) -> Vec { - fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect() -} - -fn automation_workspace(args: &Args) -> Result { - AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new)) - .map_err(|error| error.to_string()) -} - -fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { - let [input, output] = a.positional.as_slice() else { - return Err("convert needs ".into()); - }; - let opts = export_opts(a)?; - let o = files::open(Path::new(input)).map_err(|e| e.to_string())?; - warn_all(err, &o.warnings); - warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); - let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?; - warn_all(err, &ws); - Ok(()) -} - -fn print_json(out: &mut dyn Write, v: &Value, compact: bool) -> R { - let s = if compact { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) }.map_err(|e| e.to_string())?; - writeln!(out, "{s}").map_err(|e| e.to_string()) -} - -fn info(a: &Args, out: &mut dyn Write) -> R { - let [file] = a.positional.as_slice() else { - return Err("info needs ".into()); - }; - let mut h = Headless::trusted_local(); - let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?; - let mut doc = h.inspect(None).map_err(|e| e.to_string())?; - let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); - if let Some(st) = h.session.active() { - warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String)); - } - doc["warnings"] = Value::Array(warnings); - doc["file"] = json!(file); - // Drop per-session noise. - if let Value::Object(m) = &mut doc { - for k in ["history", "canUndo", "canRedo", "revision"] { - m.remove(k); - } - } - print_json(out, &doc, a.has("--compact")) -} - -/// `(id, params)` pairs from repeated `--cmd`/`--params` flags. -fn command_list(a: &Args) -> Result, String> { - let mut cmds: Vec<(String, Value)> = Vec::new(); - for (k, v) in &a.flags { - match (k.as_str(), v) { - ("--cmd", Some(id)) => cmds.push((id.clone(), json!({}))), - ("--params", Some(p)) => { - let last = cmds.last_mut().ok_or("--params must follow a --cmd")?; - last.1 = serde_json::from_str(p).map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?; - } - _ => {} - } - } - Ok(cmds) -} - -fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { - let opts = export_opts(a)?; - let mut h = Headless::trusted_local(); - // Opening, running commands and exporting can all notice the same missing font: - // one `run` invocation reports each warning line at most once. - let mut reported_fonts = std::collections::HashSet::::new(); - match (a.positional.as_slice(), a.get("--new")) { - ([file], None) => { - let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; - let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); - reported_fonts.extend(ws.iter().cloned()); - warn_all(err, &ws); - reported_fonts.extend(ws); - } - ([], Some(new)) => { - let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?; - h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?; - } - _ => return Err("run needs exactly one of or --new ".into()), - } - let cmds = command_list(a)?; - if cmds.is_empty() && a.get("--out").is_none() { - return Err("run needs at least one --cmd (or --out)".into()); - } - for (id, params) in cmds { - let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?; - if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { - return Err(format!("`{id}`: {e}")); - } - let new_warnings: Vec = h - .session - .active() - .map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc))) - .unwrap_or_default() - .into_iter() - .filter(|w| reported_fonts.insert(w.clone())) - .collect(); - warn_all(err, &new_warnings); - let mut result = json!({"command": id, "result": r}); - if !new_warnings.is_empty() { - result["warnings"] = json!(new_warnings); - } - print_json(out, &result, true)?; - } - // A `run` with only `--out` still reports missing fonts before exporting. - let remaining: Vec = h - .session - .active() - .map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc))) - .unwrap_or_default() - .into_iter() - .filter(|w| reported_fonts.insert(w.clone())) - .collect(); - warn_all(err, &remaining); - if let Some(o) = a.get("--out") { - let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?; - let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); - let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); - warn_all(err, &ws); - } - Ok(()) -} - -/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs, -/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a -/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489). -pub fn parse_actions(text: &str) -> Result, String> { - let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; - steps_of(&v) -} - -fn steps_of(v: &Value) -> Result { - photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string()) -} - -/// Engine commands to run in order: `(id, params)`. -pub type Steps = Vec<(String, Value)>; - -/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the -/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays -/// the action `name` picks (optional when the set holds one) with the whole set loaded, as -/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786). -pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec), String> { - let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?; - let list = v.get("actions").unwrap_or(&v); - let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string))); - if !is_set { - if name.is_some() { - return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into()); - } - return Ok((steps_of(&v)?, Vec::new())); - } - let set: Vec = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?; - let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); - let action = match (name, set.as_slice()) { - (Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?, - (None, [only]) => only, - (None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action ({})", set.len(), names())), - }; - let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; - Ok((play, set)) -} - -/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by -/// its version word) is converted first; the played action's steps with no PhotoCraft -/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run). -pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec, Vec), String> { - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); - if !is_atn { - let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?; - let (steps, set) = load_actions(&text, name)?; - return Ok((steps, set, Vec::new())); - } - let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?; - let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); - let action = match (name, imp.actions.as_slice()) { - (Some(n), _) => { - imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))? - } - (None, [only]) => only, - (None, []) => return Err(format!("{path}: the set holds no actions")), - (None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action ({})", imp.set, imp.actions.len(), names())), - }; - let warnings = imp - .unsupported - .iter() - .filter(|u| u.action == action.name) - .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) - .chain(imp.warnings.iter().cloned()) - .collect(); - let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; - Ok((play, imp.actions, warnings)) -} - -/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a -/// trailing separator, through a symbolic link). -fn same_dir(a: &Path, b: &Path) -> bool { - matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b) -} - -fn is_input(p: &Path) -> bool { - let known = |e: &str| { - matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub") - || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) - }; - p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) -} - -/// Drops the Save and view steps the desktop records from `action` (Batch saves each result -/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860). -fn strip_shell_steps(action: &mut Action) { - use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command}; - let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new()); - for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() { - if shell_save_command(&id) || shell_view_command(&id) { - continue; - } - if action.disabled.contains(&i) { - disabled.insert(steps.len()); - } - if action.dialogs.contains(&i) { - dialogs.insert(steps.len()); - } - steps.push((id, p)); - } - (action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs); -} - -fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { - let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; - let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); - let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); - let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?; - set.iter_mut().for_each(strip_shell_steps); - for w in &warnings { - let _ = writeln!(err, "warning: {w}"); - } - let opts = export_opts(a)?; - // Results are saved as `/.`, so an `--out` that is the `--in` folder would - // replace the originals (#492). - if !a.has("--in-place") && same_dir(&in_dir, &out_dir) { - return Err(format!( - "--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", - out_dir.display() - )); - } - std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; - let mut inputs: Vec = - std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect(); - inputs.sort(); - 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.get("--format").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 r = (|| -> Result, String> { - written.check(&target_text)?; - 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).map_err(|e| e.to_string())?; - for (id, p) in &actions { - if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) { - continue; - } - let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?; - // A failed step inside a called action makes this file an error, not a saved result. - if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) { - return Err(format!("`{id}`: {e}")); - } - } - let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?; - Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default()) - })(); - 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()); - } - } - } - let _ = writeln!(out, "{ok} succeeded, {failed} failed"); - if failed > 0 { Err(format!("{failed} file(s) failed")) } else { Ok(()) } -} - -fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { - let (file, inputs) = a.positional.split_first().ok_or("droplet needs and inputs")?; - if inputs.is_empty() { - return Err("droplet needs at least one input file or folder".into()); - } - let mut p = json!({"droplet": file, "input": inputs}); - if let Some(o) = a.get("--out") { - p["output"] = json!(o); - } - let mut s = photocraft_engine::Session::new(); - let r = s.execute("file.automate.runDroplet", p).map_err(|e| e.to_string())?; - for f in r["files"].as_array().into_iter().flatten() { - let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default()); - } - let errors = r["errors"].as_array().cloned().unwrap_or_default(); - for e in &errors { - let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default()); - } - if errors.is_empty() { Ok(()) } else { Err(format!("{} file(s) failed", errors.len())) } -} - -fn commands(a: &Args, out: &mut dyn Write) -> R { - let h = Headless::new(); - let list = h.command_list(); - let needle = a.get("--filter").map(str::to_lowercase); - let items: Vec<&Value> = list - .as_array() - .map(|v| v.iter().collect::>()) - .unwrap_or_default() - .into_iter() - .filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))) - .collect(); - if a.has("--json") { - return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false); - } - for c in items { - let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::>().join(" > ")).unwrap_or_default(); - writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu).map_err(|e| e.to_string())?; - } - Ok(()) -} - -fn serve(a: &Args, err: &mut dyn Write) -> R { - use std::sync::{Arc, Mutex}; - let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?))); - match a.get("--port") { - Some(port) => { - let port: u16 = port.parse().map_err(|_| format!("bad --port `{port}`"))?; - let addr = format!("127.0.0.1:{port}"); - let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); - let token = security::server_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?; - if let Some(path) = &token_file { - let _ = writeln!(err, "photocraft-cli control token file: {}", path.display()); - } else if supplied.is_none() { - let _ = writeln!(err, "photocraft-cli control token: {token}"); - } - photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| { - let _ = writeln!(err, "photocraft-cli serving on {local}"); - }) - .map_err(|e| e.to_string()) - } - None => { - let stdin = std::io::stdin(); - photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) - } - } -} - -fn mcp(a: &Args) -> R { - let server = match a.get("--bridge") { - Some(addr) => { - let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from)); - let token = security::client_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?; - PhotocraftMcp::bridge(addr, &token).map_err(|e| e.to_string())? - } - None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?), - }; - let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?; - rt.block_on(server.serve_stdio()).map_err(|e| e.to_string()) -} - #[cfg(test)] -mod missing_font_warning_tests { - use super::*; - - fn invoke(args: &[&str]) -> (i32, String, String) { - let mut output = Vec::new(); - let mut errors = Vec::new(); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = run(&args, &mut output, &mut errors); - (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) +pub(crate) mod test_support { + /// What [`crate::run`] returned and wrote. + pub(crate) struct Invoke { + pub(crate) code: i32, + pub(crate) stdout: String, + pub(crate) stderr: String, } - #[test] - fn missing_typeface_is_reported_by_run_info_and_convert() { - let family = "DefinitelyMissingPhotoCraftTypeface1251"; - let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id())); - std::fs::create_dir_all(&folder).unwrap(); - let native = folder.join("missing.pcraft"); - let png = folder.join("missing.png"); - let native_str = native.to_string_lossy().to_string(); - let png_str = png.to_string_lossy().to_string(); - let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string(); - - let (code, stdout, stderr) = invoke(&[ - "run", - "--new", - r#"{"width":256,"height":96,"background":"white"}"#, - "--cmd", - "type.create", - "--params", - ¶ms, - "--cmd", - "type.resolveMissingFonts", - "--out", - &native_str, - ]); - assert_eq!(code, 0, "{stderr}"); - assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning"); - let commands: Vec = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect(); - assert_eq!(commands.len(), 2, "{stdout}"); - assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); - assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported"); - - let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&stdout).unwrap(); - assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family)))); - assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr"); - - let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]); - assert_eq!(code, 0, "{stderr}"); - assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}"); - assert!(png.is_file()); - - // Even with no commands, run --out checks the opened document before export. - let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]); - assert_eq!(code, 0, "{stderr}"); - assert!(stdout.is_empty()); - assert_eq!(stderr.matches(family).count(), 1); - - let _ = std::fs::remove_dir_all(&folder); - } - - #[test] - fn convert_reads_and_writes_openraster() { - let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id())); - std::fs::create_dir_all(&folder).unwrap(); - let path = |name: &str| folder.join(name).to_string_lossy().to_string(); - let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png")); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]); - assert_eq!(code, 0, "{stderr}"); - let (code, _, stderr) = invoke(&["convert", &native, &ora]); - assert_eq!(code, 0, "{stderr}"); - let (code, stdout, stderr) = invoke(&["info", &ora, "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}"); - let (code, _, stderr) = invoke(&["convert", &ora, &png]); - assert_eq!(code, 0, "{stderr}"); - assert!(is_input(std::path::Path::new(&ora))); - // Krita documents are batch inputs too (import-only). - for name in ["sketch.kra", "old.KRZ"] { - std::fs::write(folder.join(name), b"").unwrap(); - assert!(is_input(&folder.join(name)), "{name}"); - } - let _ = std::fs::remove_dir_all(&folder); - } - - #[test] - fn no_type_layers_do_not_produce_missing_font_warnings() { - let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id())); - std::fs::create_dir_all(&folder).unwrap(); - let native = folder.join("blank.pcraft"); - let png = folder.join("blank.png"); - let native_str = native.to_string_lossy().to_string(); - let png_str = png.to_string_lossy().to_string(); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]); - assert_eq!(code, 0, "{stderr}"); - assert!(!stderr.contains("font '"), "{stderr}"); - let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&stdout).unwrap(); - assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '"))); - let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]); - assert_eq!(code, 0, "{stderr}"); - assert!(!stderr.contains("font '"), "{stderr}"); - let _ = std::fs::remove_dir_all(&folder); - } -} - -#[cfg(test)] -mod batch_action_set_tests { - use super::*; - - fn invoke(args: &[&str]) -> (i32, String, String) { - let (mut output, mut errors) = (Vec::new(), Vec::new()); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = run(&args, &mut output, &mut errors); - (code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap()) - } - - /// A batched action that plays another action of its set finds it in every file's session; a - /// failure inside the called action fails the file, and a self-call stops instead of looping. - #[test] - fn batch_plays_an_action_that_calls_another_action_of_its_set() { - let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let path = |name: &str| dir.join(name).to_string_lossy().to_string(); - std::fs::create_dir_all(dir.join("in")).unwrap(); - for name in ["a", "b"] { - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]); - assert_eq!(code, 0, "{stderr}"); - } - let set = json!({"version": 1, "actions": [ - {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]}, - {"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]}, - {"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]}, - {"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]}, - {"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]}, - ]}); - std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); - let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); - - let (code, stdout, stderr) = batch("Outer"); - assert_eq!(code, 0, "{stdout}{stderr}"); - for name in ["a", "b"] { - let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&info).unwrap(); - assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action"); - } - for action in ["Calls bad", "Loop"] { - let (code, stdout, stderr) = batch(action); - assert_ne!(code, 0, "{stdout}"); - assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}"); - assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// Save and view steps recorded inside a batched action set are skipped, also in a called - /// action, and unchecked steps stay unchecked once they are gone (#6860). - #[test] - fn batch_skips_save_and_view_steps_inside_an_action_set() { - let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let path = |name: &str| dir.join(name).to_string_lossy().to_string(); - std::fs::create_dir_all(dir.join("in")).unwrap(); - let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); - assert_eq!(code, 0, "{stderr}"); - let set = json!({"version": 1, "actions": [ - {"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]}, - {"name": "Rotate", "steps": [ - ["file.save", {}], - ["image.imageRotation.90cw", {}], - ["view.fitOnScreen", {}], - ["image.imageRotation.90cw", {}], - ["file.saveAs", {}], - ], "disabled": [3], "dialogs": [1]}, - ]}); - std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); - for action in ["Outer", "Rotate"] { - let (code, stdout, stderr) = - invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); - assert_eq!(code, 0, "{stdout}{stderr}"); - let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); - assert_eq!(code, 0, "{stderr}"); - let info: Value = serde_json::from_str(&info).unwrap(); - assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped"); - } - let _ = std::fs::remove_dir_all(&dir); + pub(crate) fn invoke(args: &[&str]) -> Invoke { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let code = crate::run(args, &mut stdout, &mut stderr); + Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() } } } diff --git a/apps/photocraft-cli/src/main.rs b/apps/photocraft-cli/src/main.rs index c0bce7826b..ea5598f99d 100644 --- a/apps/photocraft-cli/src/main.rs +++ b/apps/photocraft-cli/src/main.rs @@ -1,9 +1,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] fn main() { - // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a - // usage error, not a panic before anything is printed (issue #1108). - let args: Vec = std::env::args_os().skip(1).collect(); - let code = photocraft_cli::run_os(&args, &mut std::io::stdout(), &mut std::io::stderr()); + // `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()); std::process::exit(code); } diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs new file mode 100644 index 0000000000..34101c9ec0 --- /dev/null +++ b/apps/photocraft-cli/src/output.rs @@ -0,0 +1,51 @@ +use photocraft_io::ExportOptions; +use serde_json::Value; +use std::io::Write; + +use crate::{Result, args::ExportArgs}; + +/// The `--format` family's options. A bad value never gets here: clap refused it. +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() }; + 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. + o.encode.webp_quality = q; + o.encode.webp_lossless = false; + } + o.encode.dpx_bits = a.dpx_bits; + if let Some(c) = a.tiff_compression { + o.encode.tiff_compression = c; + } + if let Some(f) = a.jpeg_format { + o.encode.jpeg_format = f; + } + if let Some(c) = a.dds_compression { + o.encode.dds.compression = c; + } + o.encode.dds.mipmaps = a.dds_mipmaps; + o + } +} + +pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) { + for w in ws { + let _ = writeln!(err, "warning: {w}"); + } +} + +/// How [`print_json`] lays the JSON out. +pub(crate) enum JsonStyle { + Pretty, + Compact, +} + +pub(crate) fn print_json(out: &mut dyn Write, v: &Value, style: JsonStyle) -> Result { + let s = match style { + JsonStyle::Pretty => serde_json::to_string_pretty(v), + JsonStyle::Compact => serde_json::to_string(v), + }?; + Ok(writeln!(out, "{s}")?) +} diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs deleted file mode 100644 index 19258ca9ed..0000000000 --- a/apps/photocraft-cli/tests/cli.rs +++ /dev/null @@ -1,798 +0,0 @@ -//! Process-level tests of the `photocraft-cli` binary. - -use std::io::{BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -use serde_json::{Value, json}; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_photocraft-cli")) -} - -fn tmp(name: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d -} - -fn write_png(path: &Path, w: u32, h: u32, seed: u8) { - let data: Vec = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed)).collect(); - let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); - std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); -} - -fn ok(cmd: &mut Command) -> (String, String) { - let o = cmd.output().unwrap(); - let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()); - assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status); - (out, err) -} - -#[test] -fn usage_and_unknown_command() { - let o = bin().output().unwrap(); - assert_eq!(o.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&o.stderr).contains("USAGE")); - let o = bin().arg("frobnicate").output().unwrap(); - assert_eq!(o.status.code(), Some(2)); - let (out, _) = ok(bin().arg("--help")); - assert!(out.contains("convert")); - let (out, _) = ok(bin().arg("--version")); - assert!(out.starts_with("photocraft-cli ")); -} - -/// #423: ` --help` and `-h` print usage and return at once (no inputs read, no files -/// written, no server started), whatever else is on the line. -#[test] -fn every_subcommand_answers_help() { - let d = tmp("help"); - for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] { - for help in ["--help", "-h"] { - let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap(); - let out = String::from_utf8_lossy(&o.stdout); - assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr)); - assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}"); - } - } - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing"); -} - -/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work. -#[test] -fn unknown_flags_are_usage_errors() { - let d = tmp("flags"); - let input = d.join("in"); - std::fs::create_dir_all(&input).unwrap(); - write_png(&input.join("a.png"), 8, 4, 3); - let actions = d.join("actions.json"); - std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); - let out_dir = d.join("out"); - let fails = |cmd: &mut Command, flag: &str| { - let o = cmd.output().unwrap(); - let err = String::from_utf8_lossy(&o.stderr); - assert_eq!(o.status.code(), Some(2), "{err}"); - assert!(err.contains(&format!("unknown flag {flag}")), "{err}"); - }; - for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] { - fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat"); - assert!(!out_dir.exists(), "nothing written"); - } - let x = d.join("x.png"); - fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param"); - assert!(!x.exists()); - // A flag one subcommand takes is still unknown to another; a bare flag takes no value. - fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json"); - let o = bin().args(["commands", "--json=yes"]).output().unwrap(); - assert_eq!(o.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value")); - // Every flag still works in both forms. - let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"])); - assert!(out.contains("filter.blur.gaussianBlur"), "{out}"); - // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). - for needle in ["ellipse", "marquee"] { - let (out, _) = ok(bin().args(["commands", "--filter", needle])); - assert!(out.contains("select.rect"), "{needle}: {out}"); - } - ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([ - "--format=jpg", - "--quality", - "80", - ])); - assert!(out_dir.join("a.jpg").exists()); - let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact")); - assert!(!out.trim().contains('\n'), "compact JSON is one line"); -} - -#[test] -fn convert_png_pcraft_png_is_lossless() { - let d = tmp("convert"); - let (a, b, c) = (d.join("a.png"), d.join("b.pcraft"), d.join("c.png")); - write_png(&a, 37, 23, 7); - ok(bin().args(["convert"]).arg(&a).arg(&b)); - assert!(photocraft_format::is_pcraft(&std::fs::read(&b).unwrap())); - ok(bin().args(["convert"]).arg(&b).arg(&c)); - let x = photocraft_codecs::decode(&std::fs::read(&a).unwrap()).unwrap(); - let y = photocraft_codecs::decode(&std::fs::read(&c).unwrap()).unwrap(); - assert_eq!(x.to_rgba8(), y.to_rgba8()); - // Format override and lossy warning path. - ok(bin().args(["convert"]).arg(&a).arg(d.join("out.bin")).args(["--format", "jpg", "--quality", "60"])); - assert!(photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap()) == Some(photocraft_codecs::Format::Jpeg)); - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn convert_errors() { - let d = tmp("converr"); - let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); - let o = bin().args(["convert", "only-one"]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn info_prints_layer_tree_json() { - let d = tmp("info"); - let a = d.join("a.png"); - write_png(&a, 10, 6, 3); - let (out, _) = ok(bin().arg("info").arg(&a)); - let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["width"], 10); - assert_eq!(v["height"], 6); - // An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop). - assert_eq!(v["layers"][0]["name"], "Layer 0"); - assert!(v.get("history").is_none()); - let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact")); - assert_eq!(out.trim().lines().count(), 1); - std::fs::remove_dir_all(d).unwrap(); -} - -/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is -/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet. -#[test] -fn info_and_convert_report_decode_warnings() { - let d = tmp("decode-warnings"); - let noise: Vec = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect(); - let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap(); - let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap(); - // Three 1x1 frames. - let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec(); - for _ in 0..3 { - gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0"); - } - gif.push(0x3B); - // A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it - // writes, after the decode warning. - let cases = [ - ("whole.jpg", &jpeg[..], None, None), - ("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None), - ("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")), - ]; - for (name, bytes, warning, export) in cases { - let file = d.join(name); - std::fs::write(&file, bytes).unwrap(); - let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact")); - let v: Value = serde_json::from_str(&out).unwrap(); - assert_eq!(v["warnings"], json!(warning.into_iter().collect::>()), "{name}"); - let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))); - let expected: Vec = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect(); - assert_eq!( - err.trim(), - expected.join( - " -" - ), - "{name}" - ); - } - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn run_commands_and_save() { - let d = tmp("run"); - let out_pc = d.join("r.pcraft"); - let (out, _) = ok(bin() - .args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#]) - .args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]) - .args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##]) - .args(["--cmd", "document.inspect"]) - .arg("--out") - .arg(&out_pc)); - let lines: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); - assert_eq!(lines.len(), 3); - assert_eq!(lines[2]["command"], "document.inspect"); - assert_eq!(lines[2]["result"]["name"], "Run"); - let (info, _) = ok(bin().arg("info").arg(&out_pc)); - let v: Value = serde_json::from_str(&info).unwrap(); - // Saving a native document gives the reopened bundle its output filename. - assert_eq!(v["name"], "r.pcraft"); - let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); - assert_eq!(names, ["Ink", "Background"]); - // Continue from the saved file and export PNG. - let png = d.join("r.png"); - ok(bin().arg("run").arg(&out_pc).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&png)); - assert_eq!(photocraft_codecs::decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (40, 30)); - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn run_stops_after_failed_action_without_exporting() { - let d = tmp("run-failed-action"); - let out = d.join("result.png"); - let result = bin() - .args(["run", "--new", r#"{"width":8,"height":8}"#]) - .args(["--cmd", "layer.new.layer"]) - .args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#]) - .args(["--cmd", "layer.delete", "--cmd", "actions.stop"]) - // The recorded deletion succeeded with two layers; replay cannot delete the last one. - .args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#]) - .args(["--cmd", "document.inspect", "--out"]) - .arg(&out) - .output() - .unwrap(); - let error = String::from_utf8_lossy(&result.stderr); - assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout)); - assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}"); - let rows: Vec = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect(); - assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run"); - assert!(!out.exists(), "a failed action must not export a partial result"); - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn run_continues_after_successful_action_and_exports() { - let d = tmp("run-successful-action"); - let out = d.join("result.png"); - let (stdout, _) = ok(bin() - .args(["run", "--new", r#"{"width":8,"height":8}"#]) - .args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#]) - .args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"]) - .args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#]) - .args(["--cmd", "document.inspect", "--out"]) - .arg(&out)); - let rows: Vec = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect(); - let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap(); - assert_eq!(playback["result"]["ran"], 1); - assert!(playback["result"].get("failed").is_none()); - assert!(rows.iter().any(|row| row["command"] == "document.inspect")); - assert!(out.is_file()); - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn run_errors() { - let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command")); - let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); - let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); - // Params must be a JSON object, for `--new` and for each `--params`. - let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); - let e = String::from_utf8_lossy(&o.stderr); - assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}"); - let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); - let e = String::from_utf8_lossy(&o.stderr); - assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}"); -} - -#[test] -fn run_rejects_invalid_filter_parameters_without_exporting() { - let d = tmp("invalid-filter-params"); - let out = d.join("result.png"); - for (params, key) in [(r#"{"radius":"big"}"#, "radius"), (r#"{"radus":3}"#, "radus"), (r#"{"radius":5000}"#, "radius"), (r#"{"radius":-4}"#, "radius")] { - let result = bin() - .args(["run", "--new", r#"{"width":8,"height":8}"#, "--cmd", "filter.blur.gaussianBlur", "--params", params, "--out"]) - .arg(&out) - .output() - .unwrap(); - assert_eq!(result.status.code(), Some(1)); - let error = String::from_utf8_lossy(&result.stderr); - assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}"); - assert!(!out.exists()); - } - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn batch_applies_actions_to_directory() { - let d = tmp("batch"); - let (inp, outp) = (d.join("in"), d.join("out")); - std::fs::create_dir_all(&inp).unwrap(); - write_png(&inp.join("one.png"), 12, 8, 1); - write_png(&inp.join("two.png"), 9, 9, 2); - std::fs::write(inp.join("notes.txt"), "ignored").unwrap(); - let actions = d.join("actions.json"); - 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"])); - assert!(out.contains("2 succeeded, 0 failed"), "{out}"); - for n in ["one", "two"] { - let (info, _) = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))); - let v: Value = serde_json::from_str(&info).unwrap(); - assert_eq!(v["layers"][0]["name"], "Batch"); - } - // A failing action reports and exits 1. - std::fs::write(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap(); - let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).output().unwrap(); - assert_eq!(o.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&o.stderr).contains("FAIL")); - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn batch_skips_recorded_view_and_save_steps() { - let d = tmp("batch-shell-steps"); - let input = d.join("in"); - std::fs::create_dir_all(&input).unwrap(); - let source = input.join("a.png"); - write_png(&source, 12, 8, 3); - let original = std::fs::read(&source).unwrap(); - let recorded_save = d.join("recorded-save.psd"); - let mut cases = Vec::new(); - for (name, id) in [("zoom-in", "view.zoomIn"), ("zoom-out", "view.zoomOut"), ("fit", "view.fitOnScreen"), ("actual-pixels", "view.actualPixels")] { - cases.push((name, json!([[id, {}], ["image.imageRotation.90cw", {}]]).to_string())); - } - cases.push(("save", json!([["file.save", {}], ["image.imageRotation.90cw", {}]]).to_string())); - cases.push(("save-as", json!([["file.saveAs", {"path": recorded_save.to_string_lossy()}], ["image.imageRotation.90cw", {}]]).to_string())); - - for (name, actions) in cases { - let o = batch_with(&d, &input, name, &actions, &[]); - let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); - assert!(o.status.success(), "{name}: {stdout}\n{stderr}"); - assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}"); - let output = d.join(name).join("a.png"); - let (info, _) = ok(bin().arg("info").arg(&output)); - let info: Value = serde_json::from_str(&info).unwrap(); - assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}"); - assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input"); - assert!(!recorded_save.exists(), "{name} wrote the recorded Save As path"); - } - std::fs::remove_dir_all(d).unwrap(); -} - -#[test] -fn parse_actions_formats() { - let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap(); - assert_eq!(a, vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]); - assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1); - assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err()); - assert!(photocraft_cli::parse_actions("7").is_err()); - // #489: the step shapes a recorded action and a droplet store, bare or wrapped. - let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]; - for text in [ - r#"[["a",{"x":1}],["b"]]"#, - r#"[["a",{"x":1}],"b"]"#, - r#"{"steps":[["a",{"x":1}],["b",{}]]}"#, - r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#, - r#"{"actions":[["a",{"x":1}],["b",{}]]}"#, - ] { - assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}"); - } - for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] { - assert!(photocraft_cli::parse_actions(text).is_err(), "{text}"); - } -} - -/// Runs `batch` on `input` with `actions` (saved as `.json`), writing to the folder `/`. -fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output { - let file = d.join(format!("{name}.json")); - std::fs::write(&file, actions).unwrap(); - bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap() -} - -/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results -/// as the object form; a malformed step fails before anything is written. -#[test] -fn batch_accepts_recorded_action_steps() { - let d = tmp("batch-steps"); - 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"), 6, 5, 9); - let shapes = [ - ("objects", r#"[{"command":"image.adjustments.invert"}]"#), - ("pairs", r#"[["image.adjustments.invert",{}]]"#), - ("ids", r#"["image.adjustments.invert"]"#), - ("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#), - ]; - for (name, actions) in shapes { - let o = batch_with(&d, &input, name, actions, &[]); - assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr)); - assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}"); - for f in ["a.png", "b.png"] { - assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}"); - } - } - let o = batch_with(&d, &input, "bad", "[42]", &[]); - assert_eq!(o.status.code(), Some(1)); - assert!(!d.join("bad").exists(), "nothing written"); -} - -/// #490: a leading dot on `--format` doesn't double the dot in output names. -#[test] -fn batch_format_with_leading_dot() { - let d = tmp("batch-dot"); - 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"), 6, 5, 9); - let invert = r#"[{"command":"image.adjustments.invert"}]"#; - for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] { - let o = batch_with(&d, &input, name, invert, &["--format", format]); - assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr)); - let mut names: Vec = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); - names.sort(); - assert_eq!(names, ["a.jpg", "b.jpg"], "{format}"); - assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg)); - } -} - -/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size, -/// and nothing is written. -#[test] -fn quality_outside_1_to_100_is_refused() { - let d = tmp("quality"); - let input = d.join("in"); - std::fs::create_dir_all(&input).unwrap(); - let a = input.join("a.png"); - write_png(&a, 8, 4, 3); - let invert = r#"[{"command":"image.adjustments.invert"}]"#; - for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] { - let out = d.join("q.jpg"); - let (mut convert, mut run) = (bin(), bin()); - convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]); - run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]); - for cmd in [&mut convert, &mut run] { - let o = cmd.output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--quality {q}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}"); - assert!(!out.exists(), "--quality {q} wrote a file"); - } - let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]); - assert_eq!(o.status.code(), Some(1), "batch --quality {q}"); - assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder"); - } - // The documented range still encodes, lower quality making a smaller file. - let sizes: Vec = ["1", "50", "100"] - .iter() - .map(|q| { - let out = d.join(format!("q{q}.jpg")); - ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q])); - std::fs::read(out).unwrap().len() - }) - .collect(); - assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}"); -} - -/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the -/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049). -#[test] -fn dpx_converts_both_ways_with_the_bits_asked_for() { - let d = tmp("dpx"); - let a = d.join("a.png"); - write_png(&a, 8, 4, 3); - for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] { - let out = d.join("a.dpx"); - let mut convert = bin(); - convert.arg("convert").arg(&a).arg(&out); - if let Some(b) = bits { - convert.args(["--dpx-bits", b]); - } - ok(&mut convert); - let bytes = std::fs::read(&out).unwrap(); - assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}"); - } - let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")); - let v: Value = serde_json::from_str(&info).unwrap(); - assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}"); - assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}"); - let back = d.join("back.png"); - ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back)); - assert!(back.exists()); - for bad in ["24", "0", "abc"] { - let out = d.join("bad.dpx"); - let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}"); - assert!(!out.exists(), "--dpx-bits {bad} wrote a file"); - } -} - -/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes -/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that -/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327). -#[test] -fn dds_converts_both_ways_with_the_compression_asked_for() { - let d = tmp("dds"); - let a = d.join("a.png"); - write_png(&a, 8, 4, 3); - for (args, fourcc, mips) in [ - (&[][..], [0; 4], 1), - (&["--dds-compression", "bc1"][..], *b"DXT1", 1), - (&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4), - (&["--dds-compression", "bc3"][..], *b"DXT5", 1), - (&["--dds-mipmaps"][..], [0; 4], 4), - ] { - let out = d.join("a.dds"); - ok(bin().arg("convert").arg(&a).arg(&out).args(args)); - let bytes = std::fs::read(&out).unwrap(); - let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap()); - assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}"); - let back = d.join("back.png"); - ok(bin().arg("convert").arg(&out).arg(&back)); - assert!(back.exists(), "{args:?}"); - } - for bad in ["bc7", "dxt2", ""] { - let out = d.join("bad.dds"); - let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}"); - assert!(!out.exists(), "--dds-compression {bad} wrote a file"); - } -} - -/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is -/// refused and nothing is written. -#[test] -fn tga_bits_choose_the_targa_depth() { - let d = tmp("tga-bits"); - let a = d.join("a.png"); - write_png(&a, 8, 4, 3); - for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] { - let out = d.join("a.tga"); - let mut convert = bin(); - convert.arg("convert").arg(&a).arg(&out); - if let Some(b) = bits { - convert.args(["--tga-bits", b]); - } - ok(&mut convert); - assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}"); - } - for bad in ["16", "0", "abc", ""] { - let out = d.join("bad.tga"); - let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}"); - assert!(!out.exists(), "--tga-bits {bad} wrote a file"); - } -} - -/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before -/// anything is written, unless `--in-place` asks for it. -#[test] -fn batch_refuses_to_write_over_its_inputs() { - let d = tmp("batch-in-place"); - let input = d.join("in"); - std::fs::create_dir_all(&input).unwrap(); - write_png(&input.join("a.png"), 8, 4, 3); - let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap(); - std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap(); - let actions = d.join("actions.json"); - std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); - let snapshot = || { - let mut files: Vec<(PathBuf, Vec)> = - std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect(); - files.sort(); - files - }; - let before = snapshot(); - let batch = |out: &Path, extra: &[&str]| { - bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap() - }; - #[cfg(unix)] - std::os::unix::fs::symlink(&input, d.join("link")).unwrap(); - let link = cfg!(unix).then(|| PathBuf::from("link")); - for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) { - for extra in [&[][..], &["--format", "tif"][..]] { - let o = batch(out, extra); - let err = String::from_utf8_lossy(&o.stderr); - assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display()); - assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}"); - assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display()); - } - } - // A separate folder runs as before, leaving the originals alone. - let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"])); - assert!(out.contains("2 succeeded, 0 failed"), "{out}"); - assert_eq!(snapshot(), before); - // `--in-place` opts in: each result replaces its original. - let o = batch(Path::new("in"), &["--in-place"]); - assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); - let after = snapshot(); - assert_eq!(after.len(), 2); - assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced"); -} - -#[test] -fn commands_listing() { - let (out, _) = ok(bin().arg("commands")); - assert!(out.contains("file.new")); - let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"])); - let v: Value = serde_json::from_str(&out).unwrap(); - assert!( - v.as_array() - .unwrap() - .iter() - .all(|c| { c["id"].as_str().unwrap().contains("layer.new") || c["label"].as_str().unwrap().to_lowercase().contains("layer.new") }) - ); -} - -#[test] -fn in_process_run_matches_binary() { - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = photocraft_cli::run(&["commands".into(), "--filter".into(), "file.new".into()], &mut out, &mut err); - assert_eq!(code, 0); - assert!(String::from_utf8(out).unwrap().contains("file.new")); -} - -#[test] -fn mcp_stdio_handshake_and_tool_call() { - let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); - let mut stdin = child.stdin.take().unwrap(); - let mut stdout = BufReader::new(child.stdout.take().unwrap()); - let mut send = |v: Value| { - writeln!(stdin, "{v}").unwrap(); - stdin.flush().unwrap(); - }; - let mut recv = |id: u64| -> Value { - loop { - let mut line = String::new(); - assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed"); - let v: Value = serde_json::from_str(&line).unwrap(); - if v["id"] == id { - return v; - } - } - }; - send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ - "protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}})); - let init = recv(1); - assert_eq!(init["result"]["serverInfo"]["name"], "photocraft", "{init}"); - send(json!({"jsonrpc":"2.0","method":"notifications/initialized"})); - send(json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}})); - let tools = recv(2); - let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect(); - assert!(names.contains(&"command_run") && names.contains(&"doc_render_preview"), "{names:?}"); - send(json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}})); - let r = recv(3); - assert_ne!(r["result"]["isError"], true, "{r}"); - send(json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}})); - let r = recv(4); - assert_eq!(r["result"]["content"][0]["type"], "image", "{r}"); - let _ = child.kill(); - let _ = child.wait(); -} - -/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420). -#[test] -fn batch_fails_inputs_that_share_an_output_name() { - let d = tmp("batch-collide"); - let input = d.join("in"); - std::fs::create_dir_all(&input).unwrap(); - write_png(&input.join("a.png"), 8, 4, 3); - // Same stem in other formats; `A.tif` differs only in case. - let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap(); - for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] { - std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap(); - } - let actions = d.join("actions.json"); - std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); - 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); - // 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)); - assert!(out.contains("3 succeeded, 0 failed"), "{out}"); -} - -#[test] -fn droplet_runs_an_action_on_files() { - let d = tmp("droplet"); - write_png(&d.join("a.png"), 8, 4, 3); - write_png(&d.join("b.png"), 6, 2, 5); - let droplet = d.join("rot.pcdroplet"); - std::fs::write( - &droplet, - json!({"photocraftDroplet": 1, "name": "rot", "action": {"steps": [["image.imageRotation.90cw", {}]]}, "options": {"format": "png"}}).to_string(), - ) - .unwrap(); - let out_dir = d.join("out"); - let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir)); - assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}"); - let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap(); - assert_eq!(img.dimensions(), (4, 8)); - let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap(); - assert!(!o.status.success(), "not a droplet"); -} - -#[test] -fn tiff_output_is_flat_unless_tiff_layers_is_given() { - let d = tmp("tiff-layers"); - let layer_count = |path: &std::path::Path| -> usize { - let (info, _) = ok(bin().arg("info").arg(path)); - let v: Value = serde_json::from_str(&info).unwrap(); - v["layers"].as_array().unwrap().len() - }; - for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] { - let out = d.join(name); - let mut cmd = bin(); - cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]); - if let Some(flag) = flag { - cmd.arg(flag); - } - ok(cmd.arg("--out").arg(&out)); - assert_eq!(layer_count(&out), layers, "{name}"); - } - // convert honours the same flag. - let src = d.join("layered.tif"); - let flat = d.join("converted.tif"); - ok(bin().arg("convert").arg(&src).arg(&flat)); - assert_eq!(layer_count(&flat), 1); - let kept = d.join("kept.tif"); - ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers")); - assert_eq!(layer_count(&kept), 2); - 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. It is a usage error naming the argument. -#[cfg(unix)] -#[test] -fn a_non_unicode_argument_is_a_usage_error_not_a_panic() { - use std::os::unix::ffi::OsStrExt; - let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png"); - for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] { - let o = bin().args(&args).output().unwrap(); - let err = String::from_utf8_lossy(&o.stderr); - assert_eq!(o.status.code(), Some(2), "{err}"); - assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}"); - assert!(!err.contains("panicked"), "{err}"); - } -} - -/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps -/// PhotoCraft can't play are warned about up front. -#[test] -fn batch_plays_a_photoshop_actions_file() { - use photocraft_engine::actions_cmds::Action; - let d = tmp("atn"); - let input = d.join("in"); - std::fs::create_dir_all(&input).unwrap(); - write_png(&input.join("a.png"), 8, 4, 3); - let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]); - let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]); - let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap(); - assert!(skipped.is_empty()); - let atn = d.join("Set.atn"); - std::fs::write(&atn, bytes).unwrap(); - let out_dir = d.join("out"); - // Two actions: the set needs --action. - let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap(); - assert!(!o.status.success()); - assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr)); - let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)); - assert!(out_dir.join("a.png").exists()); - assert!(!err.contains("warning"), "{err}"); - // The unsupported step is warned about, and fails the file as Photoshop would stop on it. - let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap(); - let err = String::from_utf8_lossy(&o.stderr); - assert!(err.contains("Generative Fill"), "{err}"); - assert!(!o.status.success()); -} diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs new file mode 100644 index 0000000000..2c06a488fd --- /dev/null +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -0,0 +1,1423 @@ +//! Process-level tests of the `photocraft-cli` binary. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; + +/// The binary, without the variables `mcp` and `serve` fall back to: a test that wants one sets it. +fn bin() -> Command { + let mut cmd = Command::new(crate::binary()); + for var in ENV_VARS { + cmd.env_remove(var); + } + cmd +} + +const ENV_VARS: [&str; 3] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE"]; + +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn write_png(path: &Path, w: u32, h: u32, seed: u8) { + let data: Vec = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed)).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); + std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); +} + +/// What a successful run printed. +struct Printed { + stdout: String, + stderr: String, +} + +fn ok(cmd: &mut Command) -> Printed { + let o = cmd.output().unwrap(); + let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()); + assert!(o.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", o.status); + Printed { stdout, stderr } +} + +#[test] +fn usage_and_unknown_command() { + let o = bin().output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&o.stderr).contains("convert")); + let o = bin().arg("frobnicate").output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + // `help` names a subcommand or nothing: anything else is a usage error too. + let o = bin().args(["help", "frobnicate"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + assert!(!o.stderr.is_empty()); + let out = ok(bin().arg("--help")).stdout; + assert!(out.contains("convert")); + let out = ok(bin().arg("--version")).stdout; + assert!(out.starts_with("photocraft-cli ")); +} + +/// #423: ` --help` and `-h` print usage and return at once (no inputs read, no files +/// written, no server started), whatever else is on the line. +#[test] +fn every_subcommand_answers_help() { + let d = tmp("help"); + for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] { + for help in ["--help", "-h"] { + let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap(); + let out = String::from_utf8_lossy(&o.stdout); + assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr)); + assert!(out.contains(&format!("photocraft-cli {sub}")), "{sub} {help}: {out}"); + } + } + // Help where a flag's value should be is still help, not a missing value or a file named `-h`. + // So is help after another mistake. + for args in [ + &["run", "x.png", "--out", "--help"][..], + &["commands", "--filter", "-h"], + &["mcp", "--bridge", "-h"], + &["serve", "--port", "--help"], + &["mcp", "stray.psd", "--help"], + &["serve", "--port", "abc", "--port", "1", "-h"], + ] { + let o = bin().current_dir(&d).args(args).stdin(Stdio::null()).output().unwrap(); + assert_eq!(o.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&o.stderr)); + assert!(String::from_utf8_lossy(&o.stdout).contains(&format!("photocraft-cli {}", args[0])), "{args:?}"); + } + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing"); +} + +/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work. +#[test] +fn unknown_flags_are_usage_errors() { + let d = tmp("flags"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + write_png(&input.join("a.png"), 8, 4, 3); + let actions = d.join("actions.json"); + std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); + let out_dir = d.join("out"); + let fails = |cmd: &mut Command, flag: &str| { + let o = cmd.output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "{err}"); + assert!(err.contains(flag), "{err}"); + }; + for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] { + fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat"); + assert!(!out_dir.exists(), "nothing written"); + } + let x = d.join("x.png"); + fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param"); + assert!(!x.exists()); + // A flag one subcommand takes is still unknown to another; a bare flag takes no value. + fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json"); + let o = bin().args(["commands", "--json=yes"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&o.stderr).contains("--json")); + // A flag can't be a value: a missing value is reported, not filled with the next flag. + let o = bin() + .args(["batch", "--actions"]) + .arg(&actions) + .arg("--in") + .arg(&input) + .arg("--out") + .arg(&out_dir) + .args(["--format", "--quality", "80"]) + .output() + .unwrap(); + assert_eq!(o.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&o.stderr).contains("a value is required for '--format")); + assert!(!out_dir.exists(), "nothing written"); + // `--flag=value` still passes a value that starts with `--`. + ok(bin().args(["commands", "--filter=--json"])); + // Every flag still works in both forms. + let out = ok(bin().args(["commands", "--json", "--filter=gaussian"])).stdout; + assert!(out.contains("filter.blur.gaussianBlur"), "{out}"); + // The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060). + for needle in ["ellipse", "marquee"] { + let out = ok(bin().args(["commands", "--filter", needle])).stdout; + assert!(out.contains("select.rect"), "{needle}: {out}"); + } + ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([ + "--format=jpg", + "--quality", + "80", + ])); + assert!(out_dir.join("a.jpg").exists()); + let out = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact")).stdout; + assert!(!out.trim().contains('\n'), "compact JSON is one line"); +} + +fn server_usage_error(args: &[&str], expected: &str) { + let o = bin().args(args).env_remove("PHOTOCRAFT_CONTROL_TOKEN").env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE").stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "{args:?}: {err}"); + assert!(o.stdout.is_empty(), "usage errors must leave protocol stdout empty: {args:?}"); + assert!(err.contains(expected), "{args:?}: expected {expected:?} in {err}"); +} + +#[test] +fn server_modes_reject_ignored_or_conflicting_options() { + for sub in ["mcp", "serve"] { + server_usage_error(&[sub, "stray.psd"], "unexpected argument 'stray.psd'"); + for flag in ["--control-token", "--control-token-file"] { + server_usage_error(&[sub, flag, "unused"], if sub == "mcp" { "requires --bridge" } else { "requires --port" }); + } + for flag in ["--automation-read-root", "--automation-write-root"] { + server_usage_error(&[sub, flag, ".", flag, "."], "cannot be used multiple times"); + server_usage_error(&[sub, &format!("{flag}=")], &format!("a value is required for '{flag} ")); + server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("a value is required for '{flag} ")); + } + let transport = if sub == "mcp" { "--bridge" } else { "--port" }; + for flag in [transport, "--control-token", "--control-token-file"] { + // Valid values, so the repeat is what is refused (a transport value is parsed first). + server_usage_error(&[sub, flag, "7878", &format!("{flag}=7879")], "cannot be used multiple times"); + server_usage_error(&[sub, flag, ""], &format!("for '{flag} ")); + } + server_usage_error(&[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"], "cannot be used with"); + } + for flag in ["--automation-read-root", "--automation-write-root"] { + server_usage_error(&["mcp", "--bridge", "127.0.0.1:7878", flag, "."], "configure automation roots on the desktop app"); + } +} + +/// Each launch line would, before validation, have started a TCP server and written the token file. +#[test] +fn server_validation_does_not_consume_stdio_or_create_files() { + let d = tmp("server-validation"); + let token = d.join("must-not-be-created.token"); + let other = d.join("other.token"); + let token_flag = format!("--control-token-file={}", token.display()); + let other_flag = format!("--control-token-file={}", other.display()); + for tail in [&["stray"][..], &["--automation-read-root", ".", "--automation-read-root", "."], &["--port", "0"], &[&other_flag]] { + let mut child = bin() + .current_dir(&d) + .args(["serve", "--port", "0", &token_flag]) + .args(tail) + .env_remove("PHOTOCRAFT_CONTROL_TOKEN") + .env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Keep stdin open: invalid launch arguments must fail before starting a server. + let input = child.stdin.take().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while child.try_wait().unwrap().is_none() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + if child.try_wait().unwrap().is_none() { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("{tail:?}: invalid configuration started a server"); + } + drop(input); + let o = child.wait_with_output().unwrap(); + assert_eq!(o.status.code(), Some(2), "{tail:?}: {}", String::from_utf8_lossy(&o.stderr)); + assert!(o.stdout.is_empty(), "{tail:?}"); + assert!(!token.exists() && !other.exists(), "{tail:?}: validation must happen before creating credentials"); + } + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn convert_png_pcraft_png_is_lossless() { + let d = tmp("convert"); + let (a, b, c) = (d.join("a.png"), d.join("b.pcraft"), d.join("c.png")); + write_png(&a, 37, 23, 7); + ok(bin().args(["convert"]).arg(&a).arg(&b)); + assert!(photocraft_format::is_pcraft(&std::fs::read(&b).unwrap())); + ok(bin().args(["convert"]).arg(&b).arg(&c)); + let x = photocraft_codecs::decode(&std::fs::read(&a).unwrap()).unwrap(); + let y = photocraft_codecs::decode(&std::fs::read(&c).unwrap()).unwrap(); + assert_eq!(x.to_rgba8(), y.to_rgba8()); + // Format override and lossy warning path. + ok(bin().args(["convert"]).arg(&a).arg(d.join("out.bin")).args(["--format", "jpg", "--quality", "60"])); + assert!(photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap()) == Some(photocraft_codecs::Format::Jpeg)); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn convert_errors() { + let d = tmp("converr"); + let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap(); + assert_eq!(o.status.code(), Some(1)); + let o = bin().args(["convert", "only-one"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn info_prints_layer_tree_json() { + let d = tmp("info"); + let a = d.join("a.png"); + write_png(&a, 10, 6, 3); + let out = ok(bin().arg("info").arg(&a)).stdout; + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["width"], 10); + assert_eq!(v["height"], 6); + // An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop). + assert_eq!(v["layers"][0]["name"], "Layer 0"); + assert!(v.get("history").is_none()); + let out = ok(bin().arg("info").arg(&a).arg("--compact")).stdout; + assert_eq!(out.trim().lines().count(), 1); + std::fs::remove_dir_all(d).unwrap(); +} + +/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is +/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet. +#[test] +fn info_and_convert_report_decode_warnings() { + let d = tmp("decode-warnings"); + let noise: Vec = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect(); + let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap(); + let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap(); + // Three 1x1 frames. + let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec(); + for _ in 0..3 { + gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0"); + } + gif.push(0x3B); + // A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it + // writes, after the decode warning. + let cases = [ + ("whole.jpg", &jpeg[..], None, None), + ("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None), + ("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")), + ]; + for (name, bytes, warning, export) in cases { + let file = d.join(name); + std::fs::write(&file, bytes).unwrap(); + let out = ok(bin().arg("info").arg(&file).arg("--compact")).stdout; + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["warnings"], json!(warning.into_iter().collect::>()), "{name}"); + let err = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))).stderr; + let expected: Vec = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect(); + assert_eq!( + err.trim(), + expected.join( + " +" + ), + "{name}" + ); + } + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn run_commands_and_save() { + let d = tmp("run"); + let out_pc = d.join("r.pcraft"); + let out = ok(bin() + .args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#]) + .args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]) + .args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##]) + .args(["--cmd", "document.inspect"]) + .arg("--out") + .arg(&out_pc)) + .stdout; + let lines: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[2]["command"], "document.inspect"); + assert_eq!(lines[2]["result"]["name"], "Run"); + let info = ok(bin().arg("info").arg(&out_pc)).stdout; + let v: Value = serde_json::from_str(&info).unwrap(); + // Saving a native document gives the reopened bundle its output filename. + assert_eq!(v["name"], "r.pcraft"); + let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); + assert_eq!(names, ["Ink", "Background"]); + // Continue from the saved file and export PNG. + let png = d.join("r.png"); + ok(bin().arg("run").arg(&out_pc).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&png)); + assert_eq!(photocraft_codecs::decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (40, 30)); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn run_stops_after_failed_action_without_exporting() { + let d = tmp("run-failed-action"); + let out = d.join("result.png"); + let result = bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#]) + .args(["--cmd", "layer.new.layer"]) + .args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#]) + .args(["--cmd", "layer.delete", "--cmd", "actions.stop"]) + // The recorded deletion succeeded with two layers; replay cannot delete the last one. + .args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#]) + .args(["--cmd", "document.inspect", "--out"]) + .arg(&out) + .output() + .unwrap(); + let error = String::from_utf8_lossy(&result.stderr); + assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout)); + assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}"); + let rows: Vec = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run"); + assert!(!out.exists(), "a failed action must not export a partial result"); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn run_continues_after_successful_action_and_exports() { + let d = tmp("run-successful-action"); + let out = d.join("result.png"); + let stdout = ok(bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#]) + .args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#]) + .args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"]) + .args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#]) + .args(["--cmd", "document.inspect", "--out"]) + .arg(&out)) + .stdout; + let rows: Vec = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap(); + assert_eq!(playback["result"]["ran"], 1); + assert!(playback["result"].get("failed").is_none()); + assert!(rows.iter().any(|row| row["command"] == "document.inspect")); + assert!(out.is_file()); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn run_errors() { + let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap(); + assert_eq!(o.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command")); + // Neither a --cmd nor --out, and JSON that doesn't parse, are usage errors naming the flag. + let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&o.stderr).contains("--cmd")); + let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&o.stderr).contains("--new")); + // Params must be a JSON object, for `--new` and for each `--params`. + let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap(); + assert_eq!(o.status.code(), Some(1)); + let e = String::from_utf8_lossy(&o.stderr); + assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}"); + let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap(); + assert_eq!(o.status.code(), Some(1)); + let e = String::from_utf8_lossy(&o.stderr); + assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}"); +} + +#[test] +fn run_rejects_invalid_filter_parameters_without_exporting() { + let d = tmp("invalid-filter-params"); + let out = d.join("result.png"); + for (params, key) in [(r#"{"radius":"big"}"#, "radius"), (r#"{"radus":3}"#, "radus"), (r#"{"radius":5000}"#, "radius"), (r#"{"radius":-4}"#, "radius")] { + let result = bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#, "--cmd", "filter.blur.gaussianBlur", "--params", params, "--out"]) + .arg(&out) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(1)); + let error = String::from_utf8_lossy(&result.stderr); + assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}"); + assert!(!out.exists()); + } + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn batch_applies_actions_to_directory() { + let d = tmp("batch"); + let (inp, outp) = (d.join("in"), d.join("out")); + std::fs::create_dir_all(&inp).unwrap(); + write_png(&inp.join("one.png"), 12, 8, 1); + write_png(&inp.join("two.png"), 9, 9, 2); + std::fs::write(inp.join("notes.txt"), "ignored").unwrap(); + let actions = d.join("actions.json"); + 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; + assert!(out.contains("2 succeeded, 0 failed"), "{out}"); + 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(); + assert_eq!(v["layers"][0]["name"], "Batch"); + } + // A failing action reports and exits 1. + std::fs::write(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap(); + let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).output().unwrap(); + assert_eq!(o.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&o.stderr).contains("FAIL")); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn batch_skips_recorded_view_and_save_steps() { + let d = tmp("batch-shell-steps"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + let source = input.join("a.png"); + write_png(&source, 12, 8, 3); + let original = std::fs::read(&source).unwrap(); + let recorded_save = d.join("recorded-save.psd"); + let mut cases = Vec::new(); + for (name, id) in [("zoom-in", "view.zoomIn"), ("zoom-out", "view.zoomOut"), ("fit", "view.fitOnScreen"), ("actual-pixels", "view.actualPixels")] { + cases.push((name, json!([[id, {}], ["image.imageRotation.90cw", {}]]).to_string())); + } + cases.push(("save", json!([["file.save", {}], ["image.imageRotation.90cw", {}]]).to_string())); + cases.push(("save-as", json!([["file.saveAs", {"path": recorded_save.to_string_lossy()}], ["image.imageRotation.90cw", {}]]).to_string())); + + for (name, actions) in cases { + let o = batch_with(&d, &input, name, &actions, &[]); + let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + assert!(o.status.success(), "{name}: {stdout}\n{stderr}"); + assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}"); + let output = d.join(name).join("a.png"); + let info = ok(bin().arg("info").arg(&output)).stdout; + let info: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}"); + assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input"); + assert!(!recorded_save.exists(), "{name} wrote the recorded Save As path"); + } + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn parse_actions_formats() { + let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap(); + assert_eq!(a, vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]); + assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1); + assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err()); + assert!(photocraft_cli::parse_actions("7").is_err()); + // #489: the step shapes a recorded action and a droplet store, bare or wrapped. + let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]; + for text in [ + r#"[["a",{"x":1}],["b"]]"#, + r#"[["a",{"x":1}],"b"]"#, + r#"{"steps":[["a",{"x":1}],["b",{}]]}"#, + r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#, + r#"{"actions":[["a",{"x":1}],["b",{}]]}"#, + ] { + assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}"); + } + for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] { + assert!(photocraft_cli::parse_actions(text).is_err(), "{text}"); + } +} + +/// Runs `batch` on `input` with `actions` (saved as `.json`), writing to the folder `/`. +fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output { + let file = d.join(format!("{name}.json")); + std::fs::write(&file, actions).unwrap(); + bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap() +} + +/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results +/// as the object form; a malformed step fails before anything is written. +#[test] +fn batch_accepts_recorded_action_steps() { + let d = tmp("batch-steps"); + 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"), 6, 5, 9); + let shapes = [ + ("objects", r#"[{"command":"image.adjustments.invert"}]"#), + ("pairs", r#"[["image.adjustments.invert",{}]]"#), + ("ids", r#"["image.adjustments.invert"]"#), + ("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#), + ]; + for (name, actions) in shapes { + let o = batch_with(&d, &input, name, actions, &[]); + assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr)); + assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}"); + for f in ["a.png", "b.png"] { + assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}"); + } + } + let o = batch_with(&d, &input, "bad", "[42]", &[]); + assert_eq!(o.status.code(), Some(1)); + assert!(!d.join("bad").exists(), "nothing written"); +} + +/// #490: a leading dot on `--format` doesn't double the dot in output names. +#[test] +fn batch_format_with_leading_dot() { + let d = tmp("batch-dot"); + 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"), 6, 5, 9); + let invert = r#"[{"command":"image.adjustments.invert"}]"#; + for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] { + let o = batch_with(&d, &input, name, invert, &["--format", format]); + assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr)); + let mut names: Vec = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["a.jpg", "b.jpg"], "{format}"); + assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg)); + } +} + +/// #491: `--quality` outside 1-100 is a usage error for every subcommand, whatever the number's +/// size, and nothing is written. +#[test] +fn quality_outside_1_to_100_is_refused() { + let d = tmp("quality"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + let a = input.join("a.png"); + write_png(&a, 8, 4, 3); + let invert = r#"[{"command":"image.adjustments.invert"}]"#; + for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] { + let out = d.join("q.jpg"); + let (mut convert, mut run) = (bin(), bin()); + convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]); + run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]); + for cmd in [&mut convert, &mut run] { + let o = cmd.output().unwrap(); + assert_eq!(o.status.code(), Some(2), "--quality {q}"); + assert!(String::from_utf8_lossy(&o.stderr).contains("--quality"), "--quality {q}"); + assert!(!out.exists(), "--quality {q} wrote a file"); + } + let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]); + assert_eq!(o.status.code(), Some(2), "batch --quality {q}"); + assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder"); + } + // The documented range still encodes, lower quality making a smaller file. + let sizes: Vec = ["1", "50", "100"] + .iter() + .map(|q| { + let out = d.join(format!("q{q}.jpg")); + ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q])); + std::fs::read(out).unwrap().len() + }) + .collect(); + assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}"); +} + +/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the +/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is a usage error (#1049). +#[test] +fn dpx_converts_both_ways_with_the_bits_asked_for() { + let d = tmp("dpx"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] { + let out = d.join("a.dpx"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(b) = bits { + convert.args(["--dpx-bits", b]); + } + ok(&mut convert); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}"); + } + let info = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")).stdout; + let v: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}"); + assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}"); + let back = d.join("back.png"); + ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back)); + assert!(back.exists()); + for bad in ["24", "0", "abc"] { + let out = d.join("bad.dpx"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--dpx-bits {bad}"); + assert!(err.contains("--dpx-bits") && err.contains("expected 8, 10, 12 or 16"), "--dpx-bits {bad}: {err}"); + assert!(!out.exists(), "--dpx-bits {bad} wrote a file"); + } +} + +/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes +/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that +/// isn't none, bc1, bc2 or bc3 is a usage error and nothing is written (#1327). +#[test] +fn dds_converts_both_ways_with_the_compression_asked_for() { + let d = tmp("dds"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (args, fourcc, mips) in [ + (&[][..], [0; 4], 1), + (&["--dds-compression", "bc1"][..], *b"DXT1", 1), + (&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4), + (&["--dds-compression", "bc3"][..], *b"DXT5", 1), + (&["--dds-mipmaps"][..], [0; 4], 4), + ] { + let out = d.join("a.dds"); + ok(bin().arg("convert").arg(&a).arg(&out).args(args)); + let bytes = std::fs::read(&out).unwrap(); + let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap()); + assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}"); + let back = d.join("back.png"); + ok(bin().arg("convert").arg(&out).arg(&back)); + assert!(back.exists(), "{args:?}"); + } + for bad in ["bc7", "dxt2", ""] { + let out = d.join("bad.dds"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(2), "--dds-compression {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains("--dds-compression"), "--dds-compression {bad}"); + assert!(!out.exists(), "--dds-compression {bad} wrote a file"); + } +} + +/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is a +/// usage error and nothing is written. +#[test] +fn tga_bits_choose_the_targa_depth() { + let d = tmp("tga-bits"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] { + let out = d.join("a.tga"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(b) = bits { + convert.args(["--tga-bits", b]); + } + ok(&mut convert); + assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}"); + } + for bad in ["16", "0", "abc", ""] { + let out = d.join("bad.tga"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--tga-bits {bad}"); + assert!(err.contains("--tga-bits") && err.contains("expected 24 or 32"), "--tga-bits {bad}: {err}"); + assert!(!out.exists(), "--tga-bits {bad} wrote a file"); + } +} + +/// #7437: `run --out` to that same file, however `--out` is spelt, is refused before the +/// file is read or any command runs, unless `--in-place` asks for it (as `batch` does, #492). +#[test] +fn run_refuses_to_write_over_its_input() { + let d = tmp("run-in-place"); + write_png(&d.join("in.png"), 8, 4, 7); + let before = std::fs::read(d.join("in.png")).unwrap(); + let run = |out: &Path, extra: &[&str]| { + bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out"]).arg(out).args(extra).output().unwrap() + }; + let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")]; + std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap(); + outs.push(PathBuf::from("hard.png")); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap(); + outs.push(PathBuf::from("soft.png")); + } + for out in &outs { + for extra in [&[][..], &["--format", "png"][..]] { + let o = run(out, extra); + let (stdout, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display()); + assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}"); + assert!(stdout.is_empty(), "--out {} {extra:?} ran commands: {stdout}", out.display()); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "--out {} {extra:?} changed the original", out.display()); + } + } + // Another file is written as before, leaving the original alone. + ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "out.png"])); + assert!(d.join("out.png").is_file()); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before); + // `--in-place` opts in: the inverted result replaces the original, which still opens. + ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "in.png", "--in-place"])); + assert_ne!(std::fs::read(d.join("in.png")).unwrap(), before, "--in-place left the original unchanged"); + let out = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])).stdout; + let info: Value = serde_json::from_str(&out).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}"); +} + +/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it +/// doesn't name is a usage error and nothing is written. +#[test] +fn jpeg_format_chooses_the_scans() { + let d = tmp("jpeg-format"); + let a = d.join("a.png"); + write_png(&a, 16, 8, 3); + let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]); + for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] { + let out = d.join("a.jpg"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(f) = format { + convert.args(["--jpeg-format", f]); + } + ok(&mut convert); + assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}"); + } + for bad in ["Progressive", "1", ""] { + let out = d.join("bad.jpg"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--jpeg-format {bad}"); + assert!(err.contains("--jpeg-format") && err.contains("expected baseline, optimized or progressive"), "--jpeg-format {bad}: {err}"); + assert!(!out.exists(), "--jpeg-format {bad} wrote a file"); + } +} + +/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before +/// anything is written, unless `--in-place` asks for it. +#[test] +fn batch_refuses_to_write_over_its_inputs() { + let d = tmp("batch-in-place"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + write_png(&input.join("a.png"), 8, 4, 3); + let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap(); + std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap(); + let actions = d.join("actions.json"); + std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); + let snapshot = || { + let mut files: Vec<(PathBuf, Vec)> = + std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect(); + files.sort(); + files + }; + let before = snapshot(); + let batch = |out: &Path, extra: &[&str]| { + bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap() + }; + #[cfg(unix)] + std::os::unix::fs::symlink(&input, d.join("link")).unwrap(); + let link = cfg!(unix).then(|| PathBuf::from("link")); + for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) { + for extra in [&[][..], &["--format", "tif"][..]] { + let o = batch(out, extra); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display()); + assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}"); + assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display()); + } + } + // A separate folder runs as before, leaving the originals alone. + let out = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"])).stdout; + assert!(out.contains("2 succeeded, 0 failed"), "{out}"); + assert_eq!(snapshot(), before); + // `--in-place` opts in: each result replaces its original. + let o = batch(Path::new("in"), &["--in-place"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let after = snapshot(); + assert_eq!(after.len(), 2); + assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced"); +} + +/// #7437: `convert` to its own input file, however the output is spelt, is refused before +/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492). +#[test] +fn convert_refuses_to_write_over_its_input() { + let d = tmp("convert-in-place"); + write_png(&d.join("in.png"), 8, 4, 5); + let before = std::fs::read(d.join("in.png")).unwrap(); + let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap(); + let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")]; + std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap(); + outs.push(PathBuf::from("hard.png")); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap(); + outs.push(PathBuf::from("soft.png")); + } + for out in &outs { + for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] { + let o = convert(out, extra); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display()); + assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}"); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display()); + } + } + // Another file converts as before, leaving the original alone. + ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"])); + assert!(d.join("out.jpg").is_file()); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before); + // `--in-place` opts in: the result replaces the original, which still opens. + ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"])); + let Printed { stdout: out, .. } = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])); + let info: Value = serde_json::from_str(&out).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}"); +} + +#[test] +fn commands_listing() { + let out = ok(bin().arg("commands")).stdout; + assert!(out.contains("file.new")); + let out = ok(bin().args(["commands", "--json", "--filter", "layer.new"])).stdout; + let v: Value = serde_json::from_str(&out).unwrap(); + assert!( + v.as_array() + .unwrap() + .iter() + .all(|c| { c["id"].as_str().unwrap().contains("layer.new") || c["label"].as_str().unwrap().to_lowercase().contains("layer.new") }) + ); +} + +#[test] +fn in_process_run_matches_binary() { + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = photocraft_cli::run(["commands", "--filter", "file.new"], &mut out, &mut err); + assert_eq!(code, 0); + assert!(String::from_utf8(out).unwrap().contains("file.new")); +} + +#[test] +fn mcp_stdio_handshake_and_tool_call() { + let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut send = |v: Value| mcp_send(&mut stdin, v); + let mut recv = |id: u64| mcp_recv(&mut stdout, id); + send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}})); + let init = recv(1); + assert_eq!(init["result"]["serverInfo"]["name"], "photocraft", "{init}"); + send(json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + send(json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}})); + let tools = recv(2); + let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect(); + assert!(names.contains(&"command_run") && names.contains(&"doc_render_preview"), "{names:?}"); + send(json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}})); + let r = recv(3); + assert_ne!(r["result"]["isError"], true, "{r}"); + send(json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}})); + let r = recv(4); + assert_eq!(r["result"]["content"][0]["type"], "image", "{r}"); + let _ = child.kill(); + let _ = child.wait(); +} + +/// Send one JSON-RPC line to an `mcp` child. +fn mcp_send(stdin: &mut impl Write, v: Value) { + writeln!(stdin, "{v}").unwrap(); + stdin.flush().unwrap(); +} + +/// Read an `mcp` child's lines up to the reply to `id`. +fn mcp_recv(stdout: &mut impl BufRead, id: u64) -> Value { + loop { + let mut line = String::new(); + assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed"); + let v: Value = serde_json::from_str(&line).unwrap(); + if v["id"] == id { + return v; + } + } +} + +/// `mcp --bridge` takes a port or anything with a `:`; anything else is a usage error. A bare port +/// means 127.0.0.1, and the bridge connects at the first call, so with no app there the call fails +/// naming that address. +#[test] +fn mcp_bridge_takes_a_port_or_an_address() { + let o = bin().args(["mcp", "--bridge", "nope"]).stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "{err}"); + assert!(err.contains("invalid value 'nope' for '--bridge"), "{err}"); + assert!(err.contains("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)"), "{err}"); + + // Nothing listens on port 1 in a test run. The token only has to be well-formed. + let token = "a".repeat(64); + let mut child = bin() + .args(["mcp", "--bridge", "1", "--control-token", token.as_str()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + mcp_send( + &mut stdin, + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}), + ); + mcp_recv(&mut stdout, 1); + mcp_send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + mcp_send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"session_list","arguments":{}}})); + let r = mcp_recv(&mut stdout, 2); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap_or_default().contains("127.0.0.1:1"), "{r}"); + let _ = child.kill(); + let _ = child.wait(); +} + +/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420). +#[test] +fn batch_fails_inputs_that_share_an_output_name() { + let d = tmp("batch-collide"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + write_png(&input.join("a.png"), 8, 4, 3); + // Same stem in other formats; `A.tif` differs only in case. + let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap(); + for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] { + std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap(); + } + let actions = d.join("actions.json"); + std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); + 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); + // 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; + assert!(out.contains("3 succeeded, 0 failed"), "{out}"); +} + +#[test] +fn droplet_runs_an_action_on_files() { + let d = tmp("droplet"); + write_png(&d.join("a.png"), 8, 4, 3); + write_png(&d.join("b.png"), 6, 2, 5); + let droplet = d.join("rot.pcdroplet"); + std::fs::write( + &droplet, + json!({"photocraftDroplet": 1, "name": "rot", "action": {"steps": [["image.imageRotation.90cw", {}]]}, "options": {"format": "png"}}).to_string(), + ) + .unwrap(); + let out_dir = d.join("out"); + let out = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir)).stdout; + assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}"); + let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap(); + assert_eq!(img.dimensions(), (4, 8)); + let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap(); + assert!(!o.status.success(), "not a droplet"); +} + +#[test] +fn tiff_output_is_flat_unless_tiff_layers_is_given() { + let d = tmp("tiff-layers"); + let layer_count = |path: &std::path::Path| -> usize { + let info = ok(bin().arg("info").arg(path)).stdout; + let v: Value = serde_json::from_str(&info).unwrap(); + v["layers"].as_array().unwrap().len() + }; + for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] { + let out = d.join(name); + let mut cmd = bin(); + cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]); + if let Some(flag) = flag { + cmd.arg(flag); + } + ok(cmd.arg("--out").arg(&out)); + assert_eq!(layer_count(&out), layers, "{name}"); + } + // convert honours the same flag. + let src = d.join("layered.tif"); + let flat = d.join("converted.tif"); + ok(bin().arg("convert").arg(&src).arg(&flat)); + assert_eq!(layer_count(&flat), 1); + let kept = d.join("kept.tif"); + ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers")); + assert_eq!(layer_count(&kept), 2); + 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. +#[cfg(unix)] +#[test] +fn a_non_unicode_path_is_taken_and_non_unicode_text_is_a_usage_error() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + let d = tmp("non-unicode"); + let name = OsStr::from_bytes(b"caf\xe9.png"); + // No such file yet: the handler runs and fails on it (exit 1), no usage error. + for args in [vec![OsStr::new("info"), name], vec![OsStr::new("convert"), name, OsStr::new("out.png")]] { + let o = bin().current_dir(&d).args(&args).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(1), "{args:?}: {err}"); + assert!(!err.contains("panicked"), "{err}"); + } + // Linux file systems take the name (macOS ones refuse it). `convert` reads it; `info` opens + // through the automation layer, which still needs UTF-8 paths, and says so (exit 1). + if cfg!(target_os = "linux") { + write_png(&d.join(name), 8, 4, 3); + ok(bin().current_dir(&d).arg("convert").arg(name).arg("out.png")); + assert!(d.join("out.png").is_file()); + let o = bin().current_dir(&d).arg("info").arg(name).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(1), "{err}"); + assert!(err.contains("UTF-8"), "{err}"); + } + let o = bin().args([OsStr::new("commands"), OsStr::new("--filter"), name]).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!((o.status.code(), o.stdout.is_empty()), (Some(2), true), "{err}"); + assert!(err.contains("UTF-8") && !err.contains("panicked"), "{err}"); + std::fs::remove_dir_all(d).unwrap(); +} + +/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps +/// PhotoCraft can't play are warned about up front. +#[test] +fn batch_plays_a_photoshop_actions_file() { + use photocraft_engine::actions_cmds::Action; + let d = tmp("atn"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + write_png(&input.join("a.png"), 8, 4, 3); + let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]); + let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]); + let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap(); + assert!(skipped.is_empty()); + let atn = d.join("Set.atn"); + std::fs::write(&atn, bytes).unwrap(); + let out_dir = d.join("out"); + // Two actions: the set needs --action. + let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap(); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr)); + let err = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)).stderr; + assert!(out_dir.join("a.png").exists()); + assert!(!err.contains("warning"), "{err}"); + // The unsupported step is warned about, and fails the file as Photoshop would stop on it. + let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert!(err.contains("Generative Fill"), "{err}"); + assert!(!o.status.success()); +} + +/// What `photocraft_cli::run` returned and wrote, in-process. +struct Invoke { + code: i32, + stdout: String, + stderr: String, +} + +fn invoke(args: &[&str]) -> Invoke { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let code = photocraft_cli::run(args, &mut stdout, &mut stderr); + Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() } +} + +const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"]; + +/// Pins top-level dispatch: help and the version go to stdout (exit 0), no arguments is a usage +/// error on stderr. There is no `version` subcommand. +#[test] +fn top_level_help_version_and_no_arguments() { + let Invoke { code, stdout: out, stderr: err } = invoke(&[]); + assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false)); + for help in ["--help", "-h", "help"] { + let Invoke { code, stdout: out, stderr: err } = invoke(&[help]); + assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}"); + } + for version in ["--version", "-V"] { + let Invoke { code, stdout: out, stderr: err } = invoke(&[version]); + assert_eq!((code, err.as_str()), (0, ""), "{version}"); + assert!(out.starts_with("photocraft-cli ") && out.ends_with('\n') && out.lines().count() == 1, "{version}: {out}"); + } + let Invoke { code, stdout: out, stderr: err } = invoke(&["version"]); + assert_eq!((code, out.is_empty()), (2, true)); + assert!(err.contains("version"), "{err}"); + let Invoke { code, stdout: out, stderr: err } = invoke(&["frobnicate"]); + assert_eq!((code, out.is_empty()), (2, true)); + assert!(err.contains("frobnicate"), "{err}"); +} + +/// ` --help` / `-h` prints help on stdout and returns without running the subcommand +/// (`mcp` and `serve` would otherwise block on stdin). +#[test] +fn every_subcommand_help_returns_on_stdout() { + for sub in SUBCOMMANDS { + for help in ["--help", "-h"] { + let Invoke { code, stdout: out, stderr: err } = invoke(&[sub, help]); + assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}"); + // `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't. + assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}"); + } + } +} + +/// Every subcommand refuses an unknown flag (bare or `=value`) with exit 2, naming it on stderr. +#[test] +fn every_subcommand_rejects_an_unknown_flag() { + for sub in SUBCOMMANDS { + for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] { + let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect(); + let Invoke { code, stdout: out, stderr: err } = invoke(&args); + assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}"); + assert!(err.contains("--fromat"), "{args:?}: {err}"); + } + } +} + +/// `--flag=value`, a bare flag given a value, a value flag without one, repeated flags, and values +/// that start with `-`. +#[test] +fn flag_value_forms_and_repeats() { + let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::>(); + let Invoke { code, stdout: eq, stderr: err } = invoke(&["commands", "--filter=file.new"]); + assert_eq!(code, 0, "{err}"); + let spaced = invoke(&["commands", "--filter", "file.new"]).stdout; + assert_eq!(eq, spaced); + assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}"); + // A repeated flag is a usage error naming it, more likely a mistake than a choice. + for repeat in [&["commands", "--filter", "zzz-no-match", "--filter", "file.new"][..], &["commands", "--json", "--json"][..]] { + let Invoke { code, stdout: out, stderr: err } = invoke(repeat); + assert_eq!((code, out.is_empty()), (2, true), "{repeat:?}"); + assert!(err.contains(repeat[1]) && err.contains("cannot be used multiple times"), "{repeat:?}: {err}"); + } + let Invoke { code, stderr: err, .. } = invoke(&["convert", "a.png", "b.jpg", "--quality", "80", "--quality", "60"]); + assert_eq!(code, 2); + assert!(err.contains("--quality"), "{err}"); + // A value starting with `-` needs `=`; without it the token is a flag, never the value. A + // negative number is still a value. + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter=-zzz-no-match"]); + assert_eq!((code, out.as_str()), (0, ""), "{err}"); + for value in ["-zzz-no-match", "--json"] { + let Invoke { code, stdout: out, .. } = invoke(&["commands", "--filter", value]); + assert_eq!((code, out.is_empty()), (2, true), "--filter {value}"); + } + let Invoke { code, stderr: err, .. } = invoke(&["commands", "--filter", "-1"]); + assert_eq!(code, 0, "{err}"); + // A bare flag takes no value; a value flag needs one. + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--json=1"]); + assert_eq!((code, out.is_empty()), (2, true)); + assert!(err.contains("--json"), "{err}"); + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter"]); + assert_eq!((code, out.is_empty()), (2, true)); + assert!(err.contains("--filter"), "{err}"); +} + +/// Each `--params` applies to the `--cmd` before it; a `--cmd` without one gets `{}`; a +/// `--params` ahead of every `--cmd` fails naming `--params`. +#[test] +fn run_pairs_params_with_the_preceding_cmd() { + let Invoke { code, stdout: out, stderr: err } = invoke(&[ + "run", + "--new", + r#"{"width":8,"height":8}"#, + "--cmd", + "layer.new.layer", + "--params", + r#"{"name":"First"}"#, + "--cmd", + "layer.new.layer", + "--cmd", + "layer.new.layer", + "--params", + r#"{"name":"Third"}"#, + "--cmd", + "document.inspect", + ]); + assert_eq!(code, 0, "{err}"); + let rows: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(rows.len(), 4, "{out}"); + let names: Vec<&str> = rows[3]["result"]["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); + let count = |n: &str| names.iter().filter(|x| **x == n).count(); + assert_eq!((count("First"), count("Third")), (1, 1), "each --params applies to its own --cmd only: {names:?}"); + // Three new layers plus the background; the middle one (no --params) has a default name. + assert_eq!(names.len(), 4, "{names:?}"); + // Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params + // with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead. + assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}"); + let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]); + assert_ne!(code, 0); + assert!(err.contains("--params"), "{err}"); +} + +/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as +/// `run`, `info` and the app do; it wrote 72. +#[test] +fn convert_opens_a_raw_at_the_raw_defaults_resolution() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let d = tmp("convert-raw"); + let (w, h) = (24, 16); + let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd")); + std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + ok(bin().args(["convert"]).arg(&raw).arg(&psd)); + let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document; + assert_eq!(doc.resolution_dpi, 300.0); + std::fs::remove_dir_all(d).unwrap(); +} + +/// `--out=-image.png` writes `-image.png`; `--out -image.png` is a usage error and writes nothing. +#[test] +fn a_value_starting_with_a_dash_is_written_with_equals() { + let d = tmp("dash-value"); + let o = bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out", "-image.png"]).output().unwrap(); + assert_eq!(o.status.code(), Some(2), "{}", String::from_utf8_lossy(&o.stderr)); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0); + ok(bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out=-image.png"])); + assert!(d.join("-image.png").is_file()); + std::fs::remove_dir_all(d).unwrap(); +} + +/// `run` takes exactly one of `` or `--new`, and `mcp`/`serve` one of `--control-token` or +/// `--control-token-file`: anything else is a usage error naming the flags, before any work. +#[test] +fn either_or_flags_are_usage_errors() { + let Invoke { code, stderr: err, .. } = invoke(&["run", "a.png", "--new", "{}", "--cmd", "layer.new.layer"]); + assert_eq!(code, 2); + assert!(err.contains("--new") && err.contains("cannot be used with"), "{err}"); + let Invoke { code, stderr: err, .. } = invoke(&["run", "--cmd", "layer.new.layer"]); + assert_eq!(code, 2); + assert!(err.contains("--new"), "{err}"); + let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "{"]); + assert_eq!(code, 2); + assert!(err.contains("--params"), "{err}"); + let token = "a".repeat(64); + for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] { + let o = bin().args(mode).args(["--control-token", token.as_str(), "--control-token-file", "token"]).stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "{mode:?}: {err}"); + assert!(err.contains("--control-token-file") && err.contains("cannot be used with"), "{mode:?}: {err}"); + } + for port in ["70000", "x"] { + let o = bin().args(["serve", "--port", port]).stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--port {port}: {err}"); + assert!(err.contains("--port"), "--port {port}: {err}"); + } +} + +/// How a run of the binary ended. +struct Exited { + code: Option, + stderr: String, +} + +/// The binary run with `args` and one variable set, stdin at its end. +fn with_var(args: &[&str], var: &str, value: &str) -> Exited { + let o = bin().args(args).env(var, value).stdin(Stdio::null()).output().unwrap(); + Exited { code: o.status.code(), stderr: String::from_utf8_lossy(&o.stderr).into_owned() } +} + +/// A port nothing listens on, for a `serve` to take. +fn free_port() -> u16 { + std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port() +} + +/// `serve` on TCP never exits: read its stderr until it says it is serving (or its stderr closes), +/// then kill it. +fn serve_tcp_stderr(cmd: &mut Command) -> String { + use std::io::Read as _; + let mut child = cmd.stdin(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap(); + // Read on a thread, so a `serve` that stays silent with stderr open fails the test after the + // deadline instead of hanging it. + let mut stderr = child.stderr.take().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + while let Ok(n) = stderr.read(&mut buf) { + if n == 0 || tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut err = String::new(); + while !err.contains("photocraft-cli serving on") { + match rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) { + Ok(chunk) => err.push_str(&String::from_utf8_lossy(&chunk)), + Err(_) => break, + } + } + let _ = child.kill(); + let _ = child.wait(); + err +} + +/// #2491: `PHOTOCRAFT_CONTROL_PORT` turns `serve` from stdio into a TCP listener and `mcp` from a +/// headless engine into a bridge, and both say so on stderr. A flag wins, an empty value counts as +/// unset, and a bad value is a usage error naming the variable. +#[test] +fn control_port_env_switches_modes_with_a_notice_and_flags_win() { + let port = free_port(); + let err = serve_tcp_stderr(bin().arg("serve").env("PHOTOCRAFT_CONTROL_PORT", port.to_string())); + assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio")), "{err}"); + assert!(err.contains("unset it to serve stdio") && err.contains("photocraft-cli serving on"), "{err}"); + + // No token is given, so the bridge fails after the notice. + let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", &port.to_string()).stderr; + assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on 127.0.0.1:{port}")), "{err}"); + assert!(err.contains("unset it to run headless"), "{err}"); + + // The flag wins, so the variable's bad value is never parsed. + let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(port.to_string()).env("PHOTOCRAFT_CONTROL_PORT", "not-a-port")); + assert!(err.contains("photocraft-cli serving on") && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{err}"); + + // Empty or blank counts as unset: `serve` reads stdio (at its end, so it exits at once) and + // `mcp` stays headless. + // A root on the command line is the headless engine's: the variable can't switch to a bridge + // that would drop it. + for root in ["--automation-read-root", "--automation-write-root"] { + let Exited { code, stderr: err } = with_var(&["mcp", root, "."], "PHOTOCRAFT_CONTROL_PORT", &port.to_string()); + assert_eq!(code, Some(2), "{root}: {err}"); + assert!(err.contains(root) && err.contains("cannot be used with 'PHOTOCRAFT_CONTROL_PORT'") && !err.contains("bridging"), "{root}: {err}"); + } + + for empty in ["", " "] { + let Exited { code, stderr: err } = with_var(&["serve"], "PHOTOCRAFT_CONTROL_PORT", empty); + assert!(code == Some(0) && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{empty:?}: {err}"); + let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", empty).stderr; + assert!(!err.contains("bridging"), "{empty:?}: {err}"); + } + + for (sub, bad) in [ + ("serve", "not-a-port"), + ("serve", "70000"), + ("mcp", "nope"), + ("mcp", "70000"), + // `--bridge` takes host:port, the variable only a port. + ("mcp", "127.0.0.1:7878"), + ("mcp", ":7878"), + ("mcp", "bad:port"), + ] { + let Exited { code, stderr: err } = with_var(&[sub], "PHOTOCRAFT_CONTROL_PORT", bad); + assert_eq!(code, Some(2), "{sub} {bad}: {err}"); + assert!(err.contains(&format!("invalid value '{bad}' for 'PHOTOCRAFT_CONTROL_PORT'")), "{sub} {bad}: {err}"); + } +} + +/// `--control-token` and `--control-token-file` fall back to `PHOTOCRAFT_CONTROL_TOKEN` and +/// `PHOTOCRAFT_CONTROL_TOKEN_FILE`. One from the environment and the other as a flag is the +/// either/or usage error, naming the variable, when a token is used: a stale exported pair doesn't +/// stop headless `mcp` or stdio `serve`. Help names the token's variable, never its value. +#[test] +fn control_token_env_vars_are_the_flags_fallbacks() { + let token = "a".repeat(64); + for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] { + let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token", token.as_str()][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN_FILE", "token"); + assert_eq!(code, Some(2), "{mode:?}: {err}"); + assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN_FILE") && err.contains("cannot be used with"), "{mode:?}: {err}"); + let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token-file", "token"][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN", &token); + assert_eq!(code, Some(2), "{mode:?}: {err}"); + assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN") && err.contains("cannot be used with") && !err.contains(&token), "{mode:?}: {err}"); + } + for sub in ["mcp", "serve"] { + let o = bin().arg(sub).env("PHOTOCRAFT_CONTROL_TOKEN", &token).env("PHOTOCRAFT_CONTROL_TOKEN_FILE", "token").stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert!(!err.contains("cannot be used with"), "{sub}: {err}"); + // stdio `serve` exits 0 at the end of its input; how headless `mcp` ends there isn't + // pinned, so it only must not be the usage error. + if sub == "serve" { + assert_eq!(o.status.code(), Some(0), "{sub}: {err}"); + } else { + assert_ne!(o.status.code(), Some(2), "{sub}: {err}"); + } + let o = bin().args([sub, "--help"]).env("PHOTOCRAFT_CONTROL_TOKEN", &token).output().unwrap(); + let out = String::from_utf8_lossy(&o.stdout); + assert!(out.contains("[env: PHOTOCRAFT_CONTROL_TOKEN]") && !out.contains(&token), "{sub}: {out}"); + } + // The server takes the variable's token, so it neither generates nor prints one; an empty + // variable counts as unset. + let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", &token)); + assert!(err.contains("photocraft-cli serving on") && !err.contains("control token"), "{err}"); + 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}"); +} diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs new file mode 100644 index 0000000000..65092cdee4 --- /dev/null +++ b/apps/photocraft-cli/tests/it/main.rs @@ -0,0 +1,10 @@ +//! The crate's integration tests, linked into one test binary. + +mod cli; +mod man_and_completions; +mod orientation; + +/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_photocraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_photocraft-cli").into()) +} diff --git a/apps/photocraft-cli/tests/it/man_and_completions.rs b/apps/photocraft-cli/tests/it/man_and_completions.rs new file mode 100644 index 0000000000..9c1e2aabbf --- /dev/null +++ b/apps/photocraft-cli/tests/it/man_and_completions.rs @@ -0,0 +1,55 @@ +//! The clap definition is consistent, and every shell's completions and every man page +//! (`examples/man-and-completions.rs`) can be generated from it. + +use clap::ValueEnum; +use clap_complete::Shell; + +#[test] +fn command_is_consistent() { + photocraft_cli::command().debug_assert(); +} + +#[test] +fn completions_generate_for_every_shell() { + for &shell in Shell::value_variants() { + let mut out = Vec::new(); + clap_complete::generate(shell, &mut photocraft_cli::command(), "photocraft-cli", &mut out); + assert!(!out.is_empty(), "{shell}"); + } +} + +#[test] +fn man_pages_render_for_the_command_and_every_subcommand() { + let cmd = photocraft_cli::command(); + for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) { + let mut out = Vec::new(); + clap_mangen::Man::new(page.clone()).render(&mut out).unwrap(); + assert!(!out.is_empty(), "{}", page.get_name()); + } +} + +/// The example's two `generate_to` calls, repeated here, write the five completion files under the +/// names `packaging/linux/package.sh` and `packaging/freebsd/package.sh` install, `photocraft-cli.1` +/// and a page per subcommand, and no pages for the `help` subcommand. +#[test] +fn the_example_writes_the_files_the_packages_install() { + let dir = std::env::temp_dir().join(format!("pc-cli-{}-man-and-completions", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut cmd = photocraft_cli::command(); + for &shell in Shell::value_variants() { + clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", &dir).unwrap(); + } + clap_mangen::generate_to(photocraft_cli::command(), &dir).unwrap(); + + let mut written: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); + written.sort(); + let completions = ["photocraft-cli.bash", "_photocraft-cli", "photocraft-cli.fish", "photocraft-cli.elv", "_photocraft-cli.ps1"]; + let subcommands = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"]; + let mut expected: Vec = completions.map(String::from).into(); + expected.push("photocraft-cli.1".into()); + expected.extend(subcommands.iter().map(|s| format!("photocraft-cli-{s}.1"))); + expected.sort(); + assert_eq!(written, expected); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/it/orientation.rs similarity index 94% rename from apps/photocraft-cli/tests/orientation.rs rename to apps/photocraft-cli/tests/it/orientation.rs index 101195581f..8e8da558c7 100644 --- a/apps/photocraft-cli/tests/orientation.rs +++ b/apps/photocraft-cli/tests/it/orientation.rs @@ -10,7 +10,7 @@ use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Ima use serde_json::Value; fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_photocraft-cli")) + Command::new(crate::binary()) } fn tmp(name: &str) -> PathBuf { @@ -125,7 +125,8 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() { let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft")); ok(bin().arg("convert").arg(&jpg).arg(&a)); ok(bin().arg("convert").arg(&a).arg(&b)); - // Everything but the file's own path and saved document name must match. + // Everything but the file's own path and saved document name (which the first snapshot is + // named after) must match. let info = |p: &Path| { let mut v = serde_json::from_str::(&ok(bin().arg("info").arg(p))).unwrap(); v.as_object_mut().unwrap().remove("file"); @@ -136,8 +137,12 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() { assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32))); assert_eq!(ia["name"], "a.pcraft"); assert_eq!(ib["name"], "b.pcraft"); - ia.as_object_mut().unwrap().remove("name"); - ib.as_object_mut().unwrap().remove("name"); + assert_eq!(ia["snapshots"][0]["name"], "a.pcraft"); + assert_eq!(ib["snapshots"][0]["name"], "b.pcraft"); + for v in [&mut ia, &mut ib] { + v.as_object_mut().unwrap().remove("name"); + v["snapshots"][0].as_object_mut().unwrap().remove("name"); + } assert_eq!(ia, ib); let (pa, pb) = (d.join("a.png"), d.join("b.png")); ok(bin().arg("convert").arg(&a).arg(&pa)); diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 4ecad80ae4..0176e0fe84 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -29,12 +29,12 @@ log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. -eframe = { workspace = true, features = ["wgpu", "accesskit"] } +eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] } rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index a7445bbc14..a39fdfb4b4 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -9,8 +9,8 @@ use photocraft_engine::{ }; pub type Batch = BTreeMap>>; -/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action records. -pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 2; +/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action/pattern records. +pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 3; #[derive(Default)] struct State { @@ -45,6 +45,7 @@ pub fn record_limit(name: &str) -> Result { match name { INDEX_FILE => Ok(MAX_INDEX_BYTES), ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES), + PATTERNS_FILE => Ok(MAX_PATTERNS_BYTES), _ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES), _ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES), _ => Err("unknown preset record type".into()), diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 74f22b1ad7..f82051dbf9 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -4,7 +4,7 @@ use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, PoisonError}; use photocraft_codecs::{ChannelLayout, EncodeOptions, Image}; use photocraft_doc::Document; @@ -17,11 +17,12 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and -/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries. +/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .act, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", - "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", - "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", "afphoto", "afpub", + "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic", + "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act", "ase", "af", + "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; @@ -79,6 +80,7 @@ pub fn start() { canvas, options, Box::new(move |cc| { + PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx); PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); let place_inbox: Inbox = Arc::default(); @@ -217,7 +219,7 @@ fn load_font_manifest(served: Served, ctx: egui::Context) { files.entry(f.family.clone()).or_default().push((f.file.clone(), f.integrity.clone())); } log::info!("photocraft-web: {} served font families", files.len()); - served.lock().unwrap_or_else(|e| e.into_inner()).files = files; + served.lock().unwrap_or_else(PoisonError::into_inner).files = files; // The families for the font menus, and the script fallbacks (the manifest's `scripts`). served_fonts::add_fonts(&fonts); // Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets @@ -232,20 +234,21 @@ fn browser_languages() -> Vec { web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default() } -/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource -/// Integrity value), the browser rejects a body whose hash differs. +/// GET `url` (a relative path on the same origin) and read the whole body. With `integrity` (a +/// Subresource Integrity value), the browser rejects a body whose hash differs. async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result, String> { use wasm_bindgen_futures::JsFuture; + if !served_fonts::is_relative_path(url) { + return Err("url must be a relative path".into()); + } let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); let window = web_sys::window().ok_or("no window")?; - let request = match integrity { - Some(sri) => { - let init = web_sys::RequestInit::new(); - init.set_integrity(sri); - window.fetch_with_str_and_init(url, &init) - } - None => window.fetch_with_str(url), - }; + let init = web_sys::RequestInit::new(); + init.set_mode(web_sys::RequestMode::SameOrigin); + if let Some(sri) = integrity { + init.set_integrity(sri); + } + let request = window.fetch_with_str_and_init(url, &init); let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?; if !resp.ok() { return Err(format!("HTTP {}", resp.status())); @@ -293,7 +296,7 @@ impl WebShell { fn serve_fonts(&mut self, ctx: &egui::Context) { let requested = served_fonts::take_requests(); let (fetch, arrived) = { - let mut s = self.served.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner); let mut fetch = Vec::new(); for family in requested { if let Some(files) = s.files.get(&family).cloned() @@ -310,7 +313,7 @@ impl WebShell { wasm_bindgen_futures::spawn_local(async move { match fetch_bytes(&file, integrity.as_deref()).await { Ok(bytes) => { - served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes)); + served.lock().unwrap_or_else(PoisonError::into_inner).arrived.push((family, bytes)); ctx.request_repaint(); } Err(e) => log::error!("couldn't fetch served font {file}: {e}"), @@ -326,6 +329,10 @@ impl eframe::App for WebShell { // A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the // twin press closes the menu the first one opened (#2095). photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input); + // The browser build has no TCP control server, so `inject_synthetic` finds an empty + // queue, but the clipboard key mapping (`Event::PasteImage`), legacy pinch, pen-hold and + // titlebar normalization live in the app hook and must run here too. + eframe::App::raw_input_hook(&mut self.app, ctx, raw_input); } fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { @@ -345,7 +352,7 @@ impl eframe::App for WebShell { match f.bytes_async().await { Ok(bytes) => { let queue = if place { &place_inbox } else { &inbox }; - queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + queue.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes)); ctx.request_repaint(); } Err(e) => log::error!("couldn't read dropped file {name}: {e}"), @@ -354,8 +361,9 @@ impl eframe::App for WebShell { }); } self.serve_fonts(ctx); - self.app.logic(ctx, frame); + self.app.logic_frame(ctx, frame); self.save_presets(ctx); + self.app.finish_type_reset_logic(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -382,16 +390,17 @@ impl eframe::App for WebShell { }); }); } - self.app.ui(ui, frame); + self.app.ui_frame(ui, frame); self.save_presets(ui.ctx()); + self.app.finish_type_reset_pass(ui.ctx()); } } fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { Services { screen_pick: screen_color_service(), - import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| { - photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 94426b0450..181b86cfe1 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -29,19 +29,26 @@ photocraft-automation = { workspace = true } photocraft-ui-egui = { workspace = true } # Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate). photocraft-tablet = { workspace = true } -eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] } +eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence", "clipboard"] } 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.20" +roxmltree = "0.21" +craft-update = { workspace = true } rfd = { workspace = true } -arboard = { version = "3.6", features = ["wayland-data-control"] } +arboard = { version = "3.7", features = ["wayland-data-control"] } open = "5" # Reads the saved window layout (eframe persistence) to drop values that would abort startup. ron = "0.12" +# Background release checks; HTTPS with Rust TLS, no async runtime. [target.'cfg(target_os = "macos")'.dependencies] # Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target. @@ -52,22 +59,23 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di # safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" # Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the -# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in +# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the +# system alert sound for Beep When Done (services.rs, NSBeep). Already in # Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions. -objc2 = "=0.6.4" +objc2 = "=0.6.5" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] } -objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } # The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but # not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping. -muda = { version = "=0.21.1", default-features = false } +muda = { version = "=0.21.2", default-features = false } # Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs). # Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version. dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } -# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(unix)'.dependencies] rustix = { version = "=1.1.5", features = ["fs"] } +# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] photocraft-cms = { workspace = true } # The print portal's PDF: a private (0600) temporary file, removed when the dialog is done. @@ -108,3 +116,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 a75520f3c4..2784b0a3cf 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() { diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 30ae610b70..d73984eb8a 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver Receiver String { + match listener.local_addr() { + Ok(addr) => addr.to_string(), + Err(_) => format!("127.0.0.1:{requested_port}"), + } +} + fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE); } @@ -131,6 +141,17 @@ mod tests { use super::*; use std::io::BufRead; + /// #6427: with port 0 the log names the port the OS bound, which a client can connect to. + #[test] + fn port_zero_logs_the_bound_port() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let bound = listener.local_addr().unwrap(); + assert_ne!(bound.port(), 0); + let logged = listening_addr(&listener, 0); + assert_eq!(logged, format!("127.0.0.1:{}", bound.port())); + TcpStream::connect(&logged).unwrap(); + } + /// #4965: a connection that keeps sending without authenticating is closed at the deadline /// (releasing its connection permit), and nothing it sent is dispatched. #[test] diff --git a/apps/photocraft/src/external_svg.rs b/apps/photocraft/src/external_svg.rs index 3cf6c4a2b3..6ed3dacf40 100644 --- a/apps/photocraft/src/external_svg.rs +++ b/apps/photocraft/src/external_svg.rs @@ -3,7 +3,7 @@ //! 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}; +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> { @@ -34,7 +34,17 @@ mod native { let portal = matches!(&editor, ExternalSvgEditor::VectorCraft); #[cfg(not(target_os = "linux"))] let portal = false; + let vectorcraft = matches!(&editor, ExternalSvgEditor::VectorCraft); let executable = if portal && source.extension == "svg" { None } else { Some(resolve_editor(editor)?) }; + // Display conversion is always performed by VectorCraft's CLI, even when the user + // chooses a different interactive editor. Explicit VectorCraft builds keep their matching CLI. + let converter = if source.extension == "svg" { + None + } else if vectorcraft { + executable.clone() + } else { + Some(resolve_converter(executable.as_deref())?) + }; 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()); } @@ -45,7 +55,7 @@ mod native { thread::Builder::new() .name("photocraft-vector-editor".into()) .spawn(move || { - let prepared = match executable.as_deref() { + let prepared = match converter.as_deref() { Some(executable) => super::vector_conversion::prepare(executable, &source, &stopped), None => OwnedSvg::create(&source.bytes).map(|owned| (owned, source.bytes.as_ref().clone())), }; @@ -57,16 +67,18 @@ mod native { #[cfg(target_os = "linux")] if portal { match super::portal::open(&owned, &stopped) { - Ok(()) => watch(owned, initial, None, stopped, send, POLL_INTERVAL, SAVE_SETTLE), + 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 { + if let Some(executable) = executable.as_ref() { match std::process::Command::new(executable).arg(&owned.file).spawn() { - Ok(child) => watch(owned, initial, Some(child), stopped, send, POLL_INTERVAL, SAVE_SETTLE), + 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}"))); } @@ -82,6 +94,20 @@ mod native { Ok(Box::new(Session { stop, updates, finished: false })) } + /// An explicit VectorCraft build may carry its own CLI. Other editors use the known + /// VectorCraft installation; document metadata never participates in discovery. + fn resolve_converter(selected: Option<&Path>) -> Result { + let name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" }; + if let Some(editor) = selected + && let Some(directory) = editor.parent() + && std::fs::symlink_metadata(directory.join(name)).is_ok_and(|m| m.is_file() && !m.file_type().is_symlink()) + { + return Ok(editor.to_path_buf()); + } + resolve_editor(ExternalSvgEditor::VectorCraft) + .map_err(|_| "AI/PDF/EPS editing also needs an installed VectorCraft CLI to validate display previews".into()) + } + /// Discover only known installations or an explicitly chosen executable. #[cfg(unix)] fn resolve_editor(editor: ExternalSvgEditor) -> Result { @@ -119,6 +145,8 @@ mod native { 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 { @@ -162,6 +190,8 @@ mod native { } } + /// 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")) { @@ -181,6 +211,14 @@ mod native { 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()); } @@ -208,7 +246,7 @@ mod native { } .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("Artwork.svg"); + 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 }) @@ -269,6 +307,7 @@ mod native { } /// Write normalized SVG through a checked, no-follow handle before editor launch. + #[cfg(test)] 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()); @@ -300,6 +339,7 @@ mod native { } } + /// 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()); @@ -307,6 +347,8 @@ mod native { 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; @@ -347,14 +389,20 @@ mod native { } } - struct Session { + pub(super) struct Session { stop: Sender<()>, - updates: Receiver, String>>, + 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, @@ -365,6 +413,7 @@ mod native { } } + /// True once a poll has seen the watcher thread exit and drop its sender. fn finished(&self) -> bool { self.finished } @@ -385,24 +434,80 @@ mod native { 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)) + .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 }) } + #[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, String>>, - interval: Duration, - settle: Duration, + 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; @@ -428,18 +533,52 @@ mod native { }; match candidate { Ok(bytes) => { - last_error = None; - if bytes == last_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; } - match send.try_send(Ok(bytes.clone())) { - Ok(()) => { - last_bytes = bytes; - last_stamp = pending.as_ref().map(|(stamp, _)| stamp.clone()); + 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; + } + } } - Err(TrySendError::Full(_)) => {} - Err(TrySendError::Disconnected(_)) => break, + 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) => { @@ -461,6 +600,29 @@ mod native { mod tests { use super::*; + #[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); + } + const EMPTY_SAVE: &str = "The edited SVG must contain between 1 byte and 16 MiB"; const CHANGING_SAVE: &str = "The SVG is still being saved"; @@ -500,7 +662,7 @@ mod native { let (stop, _stopped) = mpsc::channel(); let (send, receive) = mpsc::channel(); for update in updates { - send.send(update).unwrap(); + send.send(update.map(|bytes| ExternalVectorSave { bytes, preview_svg: None })).unwrap(); } Session { stop, updates: receive, finished: false } } @@ -638,3 +800,7 @@ 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/launch_args.rs b/apps/photocraft/src/launch_args.rs new file mode 100644 index 0000000000..5e54f84ed8 --- /dev/null +++ b/apps/photocraft/src/launch_args.rs @@ -0,0 +1,340 @@ +//! The desktop app's command line, as clap types (#7071): `photocraft --help` comes from these doc +//! comments, so the help and the parser can't disagree. Anything they don't declare is a usage +//! error naming it (exit status 2), so a typo such as `--safegpu` doesn't open the window and then +//! fail to open the flag as a file (#6431); so is a flag without its value, rather than a silent +//! fall back to a generated token or to no root (#6429), a repeated flag, and a bad +//! `--present-mode` or `--frame-latency`. A value never starts with `-` (write `--opt=-value`), +//! except for negative numbers, and a file whose name starts with `-` goes after `--`. +//! +//! The control and automation flags are `photocraft-automation`'s, shared with `photocraft-cli`. +//! Only the desktop app reads the automation roots from `PHOTOCRAFT_AUTOMATION_*_ROOT` (#1551). + +use std::ffi::OsString; +use std::fmt; +use std::path::PathBuf; + +use clap::builder::FalseyValueParser; +use clap::parser::ValueSource; +use clap::{Command, CommandFactory, FromArgMatches, Parser}; +use eframe::wgpu::PresentMode; +use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars}; + +use crate::presentation; + +/// Opens PhotoCraft with the given files +#[derive(Parser)] +#[command(name = "photocraft", version = photocraft_engine::build_info::long_version_static(), after_help = ENVIRONMENT)] +pub(crate) struct LaunchArgs { + /// Files to open. On Windows and Linux they open as new tabs in the PhotoCraft already running. + /// Put `--` before a name that starts with `-` + pub(crate) files: Vec, + /// Start with the CPU renderer for this launch (after a graphics driver crash) + #[arg(long)] + pub(crate) safe_gpu: bool, + /// macOS: keep the menus inside the window instead of the menu bar (the variable: 0, false, no + /// or off is off; empty is unset) + #[arg(long, env = "PHOTOCRAFT_IN_WINDOW_MENUS", value_parser = FalseyValueParser::new())] + pub(crate) in_window_menus: bool, + /// Windows and Linux: start a separate PhotoCraft instead of opening the files in the one + /// already running + #[arg(long)] + pub(crate) new_instance: bool, + /// How frames reach the display: fifo, autovsync or novsync (also immediate or mailbox). + /// Without it, PHOTOCRAFT_PRESENT_MODE + #[arg(long, value_name = "MODE", value_parser = present_mode)] + pub(crate) present_mode: Option, + /// Frames queued for the display, 1 to 3. Without it, PHOTOCRAFT_FRAME_LATENCY + // allow_negative_numbers makes `--frame-latency -1` a bad value rather than an unexpected argument. + #[arg(long, value_name = "1-3", value_parser = frame_latency, allow_negative_numbers = true)] + pub(crate) frame_latency: Option, + /// Time each brush stroke's frames: stderr prints a summary per stroke, a CSV file path also + /// records every frame. Without it, PHOTOCRAFT_STROKE_TRACE + #[arg(long, value_name = "stderr|FILE.csv")] + pub(crate) stroke_trace: Option, + /// Start the localhost JSON-lines control server on this port + #[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)] + pub(crate) control: Option, + #[command(flatten)] + pub(crate) auth: ControlArgs, + #[command(flatten)] + pub(crate) automation: AutomationArgs, + /// The per-process options given on the command line, set by [`parse`]. + #[arg(skip)] + pub(crate) launch_options: Vec, +} + +/// The variables that aren't a flag's fallback, for the end of `--help`. +const ENVIRONMENT: &str = "\ +Environment: PHOTOCRAFT_NEW_INSTANCE (anything but 0 or empty: as --new-instance), PHOTOCRAFT_CONFIG_DIR (the settings folder)"; + +/// An option that applies only to the process it's given to: a launch with any of them keeps its +/// own process instead of handing its files to the running one (`single_instance`). Shown as the +/// flag, or the variable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PerProcess { + AutomationReadRoot, + AutomationWriteRoot, + SafeGpu, + InWindowMenus, + PresentMode, + FrameLatency, + StrokeTrace, + /// PHOTOCRAFT_GPU_ADAPTER, when startup will act on it (`gpu_startup`). Not a flag. + GpuAdapterEnv, +} + +impl PerProcess { + /// The flags, in the order a log line lists them. A flag's variable asks for the same thing, so + /// it counts too (#7242); the variables that aren't a flag's fallback are counted by + /// `single_instance::env_launch_options`. + pub(crate) const FLAGS: [PerProcess; 7] = + [Self::AutomationReadRoot, Self::AutomationWriteRoot, Self::SafeGpu, Self::InWindowMenus, Self::PresentMode, Self::FrameLatency, Self::StrokeTrace]; + + /// The flag's clap argument id. + fn id(self) -> Option<&'static str> { + Some(match self { + Self::AutomationReadRoot => "automation_read_root", + Self::AutomationWriteRoot => "automation_write_root", + Self::SafeGpu => "safe_gpu", + Self::InWindowMenus => "in_window_menus", + Self::PresentMode => "present_mode", + Self::FrameLatency => "frame_latency", + Self::StrokeTrace => "stroke_trace", + Self::GpuAdapterEnv => return None, + }) + } +} + +impl fmt::Display for PerProcess { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::AutomationReadRoot => "--automation-read-root", + Self::AutomationWriteRoot => "--automation-write-root", + Self::SafeGpu => "--safe-gpu", + Self::InWindowMenus => "--in-window-menus", + Self::PresentMode => "--present-mode", + Self::FrameLatency => "--frame-latency", + Self::StrokeTrace => "--stroke-trace", + Self::GpuAdapterEnv => "PHOTOCRAFT_GPU_ADAPTER", + }) + } +} + +/// The files to open, as [`LaunchArgs::file_names`] sorts them. +#[derive(Debug, PartialEq)] +pub(crate) struct FileNames { + pub(crate) openable: Vec, + /// Names that are not valid Unicode, shown lossily: reported, not opened. + pub(crate) unreadable: Vec, +} + +impl LaunchArgs { + /// The files to open, and those whose names are not valid Unicode (#1108): a file manager can + /// hand over a Latin-1 name on Linux. Those are reported in a notice once the app is up. + pub(crate) fn file_names(&self) -> FileNames { + let mut names = FileNames { openable: Vec::new(), unreadable: Vec::new() }; + for f in &self.files { + match f.to_str() { + Some(s) => names.openable.push(s.to_owned()), + None => names.unreadable.push(f.to_string_lossy().into_owned()), + } + } + names + } +} + +/// The command line, with the desktop app's variables for the automation roots. An empty or blank +/// variable counts as unset. +pub(crate) fn command() -> Command { + LaunchArgs::command() + .mut_arg("automation_read_root", |a| a.env("PHOTOCRAFT_AUTOMATION_READ_ROOT")) + .mut_arg("automation_write_root", |a| a.env("PHOTOCRAFT_AUTOMATION_WRITE_ROOT")) + .mut_args(blank_env_is_unset) +} + +/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS +/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless +/// it comes after `--`. A bad value from a variable is a usage error naming the variable. +pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator) -> Result { + let mut options = true; + let args: Vec = args + .into_iter() + .filter(|a| { + options &= a != "--"; + !(options && a.to_str().is_some_and(|s| s.starts_with("-psn_"))) + }) + .collect(); + let argv = std::iter::once(OsString::from("photocraft")).chain(args.iter().cloned()); + let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?; + let mut launch = LaunchArgs::from_arg_matches(&matches)?; + // Only the control server reads a token, so a stale exported pair doesn't stop a launch. + if launch.control.is_some() { + conflict(&mut cmd, &matches, "control_token", "control_token_file")?; + } + // `PHOTOCRAFT_IN_WINDOW_MENUS=0` (or off, false, no) is a variable that turns the menus off. + let menus = launch.in_window_menus; + let given = |p: &PerProcess| match p.id().and_then(|id| matches.value_source(id)) { + Some(ValueSource::CommandLine) => true, + Some(ValueSource::EnvVariable) => *p != PerProcess::InWindowMenus || menus, + _ => false, + }; + launch.launch_options = PerProcess::FLAGS.into_iter().filter(given).collect(); + Ok(launch) +} + +/// Why a value was refused: clap prints it after the flag and its value (exit 2). +#[derive(Debug, thiserror::Error)] +enum ValueError { + #[error("expected fifo, autovsync or novsync (or immediate, mailbox)")] + PresentMode, + #[error("expected 1, 2 or 3")] + FrameLatency, +} + +fn present_mode(s: &str) -> Result { + presentation::present_mode(s).ok_or(ValueError::PresentMode) +} + +fn frame_latency(s: &str) -> Result { + presentation::frame_latency(s).ok_or(ValueError::FrameLatency) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + + /// Parse without the variables, so one exported where the tests run can't change them (the + /// process tests in `tests/command_line_usage.rs` cover the variables). + fn parse_args(args: &[&str]) -> Result { + parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from)) + } + + fn usage_error(args: &[&str]) -> String { + let Err(e) = parse_args(args) else { panic!("{args:?} parsed") }; + assert_eq!(e.exit_code(), 2, "{args:?}: {e}"); + e.to_string() + } + + #[test] + fn control_port_rejects_unparseable_values_naming_them() { + // Out of range, negative and empty values fail, naming the value (#701); a following + // flag is never taken as the value. + assert!(usage_error(&["--control", "78787"]).contains("invalid value '78787' for '--control '")); + assert!(usage_error(&["--control", "-1"]).contains("invalid value '-1'")); + assert!(usage_error(&["--control="]).contains("invalid value '' for '--control '")); + assert!(usage_error(&["--control", "--safe-gpu"]).contains("a value is required for '--control '")); + } + + #[test] + fn flags_without_a_value_are_usage_errors() { + // #6429: a missing value fails the launch, rather than falling back to a generated token + // or to no root. The tuning flags too, now (#7071). + for flag in [ + "--control", + "--control-token", + "--control-token-file", + "--automation-read-root", + "--automation-write-root", + "--present-mode", + "--frame-latency", + "--stroke-trace", + ] { + let err = usage_error(&[flag]); + assert!(err.contains(&format!("a value is required for '{flag} <")), "{flag}: {err}"); + } + } + + #[test] + fn bad_tuning_values_and_repeats_are_usage_errors() { + assert!(usage_error(&["--present-mode", "fast"]).contains("expected fifo, autovsync or novsync")); + for latency in ["0", "4", "-1", "one"] { + let err = usage_error(&["--frame-latency", latency]); + assert!(err.contains(&format!("invalid value '{latency}' for '--frame-latency <1-3>'")), "{err}"); + } + assert!(usage_error(&["--safe-gpu", "--safe-gpu"]).contains("cannot be used multiple times")); + assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times")); + // A token and a token file are either/or for the control server, and ignored without it. + let pair = ["--control-token", "abcd", "--control-token-file", "/tmp/token"]; + assert!(usage_error(&[&["--control", "1"][..], &pair[..]].concat()).contains("cannot be used with")); + assert!(parse_args(&pair).is_ok()); + } + + #[test] + fn values_are_parsed_and_per_process_options_recorded() { + let parsed = parse_args(&[ + "--stroke-trace", + "stderr", + "--control", + "50494", + "--control-token", + "abcd", + "--automation-read-root", + "/r", + "--automation-write-root", + "/w", + "--safe-gpu", + "--in-window-menus", + "--new-instance", + "--present-mode", + "Mailbox", + "--frame-latency", + "1", + "photo.psd", + ]) + .unwrap(); + assert_eq!(parsed.control, Some(50494)); + assert_eq!(parsed.auth.control_token.as_deref(), Some("abcd")); + assert_eq!(parsed.auth.control_token_file, None); + assert_eq!(parsed.automation.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); + assert_eq!(parsed.automation.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); + assert!(parsed.safe_gpu && parsed.in_window_menus && parsed.new_instance); + assert_eq!(parsed.present_mode, Some(PresentMode::AutoNoVsync)); + assert_eq!(parsed.frame_latency, Some(1)); + assert_eq!(parsed.stroke_trace.as_deref(), Some("stderr")); + // In a fixed order: `single_instance` only lists them in a log line. + assert_eq!(parsed.launch_options, PerProcess::FLAGS); + assert_eq!(parsed.file_names(), FileNames { openable: vec!["photo.psd".to_string()], unreadable: vec![] }); + + let parsed = parse_args(&["--control-token-file", "/tmp/token", "--new-instance"]).unwrap(); + assert_eq!(parsed.auth.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); + assert!(parsed.launch_options.is_empty() && !parsed.in_window_menus, "{:?}", parsed.launch_options); + } + + #[test] + fn version_stops_parsing() { + let e = parse_args(&["--version", "--control"]).err().unwrap(); + assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0)); + assert_eq!(e.to_string(), format!("photocraft {}\n", photocraft_engine::build_info::long_version())); + } + + #[test] + fn help_stops_parsing_and_double_dash_ends_options() { + let e = parse_args(&["-h", "--safegpu", "--control"]).err().unwrap(); + assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0)); + + assert!(usage_error(&["--safegpu", "--", "--help"]).contains("unexpected argument '--safegpu'")); + let parsed = parse_args(&["a.psd", "--", "--help", "-x"]).unwrap(); + assert_eq!(parsed.file_names().openable, ["a.psd", "--help", "-x"]); + } + + #[test] + fn a_finder_process_serial_number_is_dropped() { + let parsed = parse_args(&["-psn_0_12345", "a.psd"]).unwrap(); + assert_eq!(parsed.file_names().openable, ["a.psd"]); + let parsed = parse_args(&["--", "-psn_x.psd"]).unwrap(); + assert_eq!(parsed.file_names().openable, ["-psn_x.psd"]); + } + + /// #1108: a name that isn't valid Unicode is reported, not opened; a token must be text. + #[cfg(unix)] + #[test] + fn non_unicode_file_names_are_reported_and_tokens_rejected() { + use std::os::unix::ffi::OsStringExt as _; + let latin1 = OsString::from_vec(b"caf\xe9.psd".to_vec()); + let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone()]).unwrap(); + assert_eq!(parsed.file_names(), FileNames { openable: vec![], unreadable: vec!["caf\u{fffd}.psd".to_string()] }); + let e = parse(command().mut_args(|a| a.env(None::<&str>)), [OsString::from("--control-token"), latin1]).err().unwrap(); + assert_eq!((e.kind(), e.exit_code()), (ErrorKind::InvalidUtf8, 2)); + } +} diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 5c8f0697dd..6a0cc8f052 100644 --- a/apps/photocraft/src/logging.rs +++ b/apps/photocraft/src/logging.rs @@ -91,27 +91,36 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), +/// so month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -416,6 +425,20 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } } #[test] diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 3186b53883..2012dfd09d 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -1,14 +1,7 @@ //! PhotoCraft desktop app. //! -//! Usage: `photocraft [--control ] [--control-token <64-hex> | -//! --control-token-file ] [--automation-read-root ] -//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [--present-mode ] -//! [--frame-latency ] [--stroke-trace ] [--new-instance] [--] [files…]` -//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version). -//! -//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo -//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file -//! whose name starts with `-` goes after `--`. +//! Usage: `photocraft [options] [--] [files…]`; `photocraft --help` lists the options, which are +//! declared, with what makes a usage error (exit status 2), in `launch_args`. //! //! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new //! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any @@ -31,7 +24,8 @@ //! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods. // Release builds on Windows are GUI-subsystem apps, so launching from the Start Menu or Explorer -// doesn't open a console window. (`--version` output then only shows when redirected.) +// doesn't open a console window. (`--help`, `--version` and usage errors then only show when +// redirected.) #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -49,6 +43,7 @@ mod file_dialog_probe; mod gpu_startup; #[cfg(target_os = "linux")] mod icon_theme; +mod launch_args; #[cfg(target_os = "macos")] mod mac_menu; #[cfg(target_os = "macos")] @@ -76,6 +71,7 @@ mod single_instance; // Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button). mod tablet; mod ui_state; +mod updates; mod window_frame; use photocraft_engine::Session; @@ -98,6 +94,53 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { CUSTOM_TITLEBAR && !prefs.interface.system_title_bar } +/// Preferences › Integrations, read before the window opens the way [`custom_titlebar`] is: +/// whether agents may drive this launch at all, and the port to listen on when no `--control` +/// was given (0 = only with `--control`). A missing or unreadable file keeps the defaults. +/// +/// The section is read on its own, and when it doesn't read as a whole each value on its own, +/// as the app's lenient load reads them (#6611): a wrong-typed value elsewhere in the file, or +/// in the other field, must not turn Allow Agent Control back on while the dialog shows it off. +fn integrations(prefs_file: Option<&std::path::Path>) -> photocraft_engine::prefs::Integrations { + use serde_json::Value; + let mut out = photocraft_engine::prefs::Preferences::default().integrations; + let file: Option = prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()); + let Some(section) = file.as_ref().and_then(|v| v.get("integrations")) else { + return out; + }; + if let Ok(whole) = serde_json::from_value(section.clone()) { + return whole; + } + if let Some(allow) = section.get("allowAgentControl").and_then(Value::as_bool) { + out.allow_agent_control = allow; + } + if let Some(port) = section.get("controlPort").and_then(Value::as_u64).and_then(|p| u32::try_from(p).ok()) { + out.control_port = port; + } + out +} + +/// The control port this launch listens on, and the warning to print when a request was refused. +/// `--control` (or `PHOTOCRAFT_CONTROL_PORT`) wins over the preference; Allow Agent Control off +/// refuses both, and says so rather than ignoring the flag silently. A preferred port outside +/// 1..=65535 is refused the same way. +fn control_port_for(cli: Option, prefs: &photocraft_engine::prefs::Integrations) -> (Option, Option) { + if !prefs.allow_agent_control { + let warning = cli.map(|p| format!("photocraft: Preferences › Integrations › Allow Agent Control is off: not listening on port {p}\n")); + return (None, warning); + } + if let Some(port) = cli { + return (Some(port), None); + } + match prefs.control_port { + 0 => (None, None), + p => match u16::try_from(p).ok().filter(|p| *p != 0) { + Some(port) => (Some(port), None), + None => (None, Some(format!("photocraft: Preferences › Integrations › Control Port {p} is not a usable port: not listening\n"))), + }, + } +} + /// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame /// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without /// `centered`, Windows cascades each new window from the top-left corner, so it opened at a @@ -117,6 +160,9 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { .with_titlebar_shown(false) .with_title_shown(false), centered: true, + // PhotoCraft owns its fallback fonts (`cjk_fonts`, craft-fonts): eframe's system-font + // provider would compete with it and make glyph choice machine-dependent (#8512). + system_font_fallback: false, // eframe saves native window geometry and egui panel/window sizes on exit. // Keep that state beside preferences, including config overrides and portable mode. persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")), @@ -149,360 +195,39 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde builder } -/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that -/// names the value (issue #701): silently running with no control server leaves a launcher or -/// agent unable to tell a typo from a successful grant. -fn parse_control_port(value: &str, source: &str) -> Result { - value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) -} - -/// Process exit status for the collected usage errors (a bad `--control` / -/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a -/// command-line usage error), never 0. -fn usage_exit_code(errors: &[String]) -> Option { - if errors.is_empty() { None } else { Some(2) } -} - -/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs. -const USAGE: &str = "\ -Usage: photocraft [options] [--] [files…] - -Opens PhotoCraft with the given files. - -Options: - -h, --help Print this help and exit - --version Print the version, commit and build date and exit - --safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash) - --in-window-menus macOS: keep the menus inside the window instead of the menu bar - --new-instance Windows and Linux: start a separate PhotoCraft instead of opening - the files in the one already running - --present-mode How frames reach the display: fifo, autovsync or novsync - --frame-latency Frames queued for the display: 1 to 3 - --stroke-trace - Time each brush stroke's frames - --control Start the localhost JSON-lines control server on this port - --control-token <64-hex> Control server token (default: a new random one, printed) - --control-token-file Read the control server token from this file, or create it - with a new one - --automation-read-root Directory the control server may read files from - --automation-write-root - Directory the control server may write files to - -- Treat every later argument as a file, even one starting with `-` - -A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored. -Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE, -PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE, -PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE, -PHOTOCRAFT_CONFIG_DIR. -"; - -/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when -/// stdout is closed or a pipe (`photocraft --help | head -1`). -fn print_stdout(text: &str) { - use std::io::Write as _; - let mut out = std::io::stdout().lock(); - let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush()); -} - -/// Everything the desktop launcher takes from the command line, on top of the environment -/// defaults handed in to `parse_launch_args`. -#[derive(Debug, Default, PartialEq, Eq)] -struct LaunchArgs { - control_port: Option, - control_token: Option, - control_token_file: Option, - automation_read_root: Option, - automation_write_root: Option, - files: Vec, - /// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1 - /// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are - /// reported in a notice once the app is up; a control token is a usage error like a bad port. - unreadable_paths: Vec, - safe_gpu: bool, - new_instance: bool, - /// Options that apply only to the process they're given to; a launch with any keeps its own. - launch_options: Vec<&'static str>, - present_mode_arg: Option, - frame_latency_arg: Option, - stroke_trace_arg: Option, - in_window_menus: bool, - /// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there. - help: bool, - /// `--version`: print the version and exit 0; parsing stops there. - version: bool, - /// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty - /// list fails the launch with exit status 2 (issues #701, #6429, #6431). - errors: Vec, -} - -/// Parse the desktop launch arguments over the environment defaults in `out`. A control or -/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent -/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2. -/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`. -fn parse_launch_args(args: impl Iterator, mut out: LaunchArgs) -> LaunchArgs { - let mut args = args; - // After `--`, every argument is a file, even one that starts with `-`. - let mut only_files = false; - while let Some(a) = args.next() { - if only_files { - match a.into_string() { - Ok(s) => out.files.push(s), - Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()), - } - continue; - } - match a.to_str() { - Some("--") => only_files = true, - Some("--help" | "-h") => { - out.help = true; - break; - } - Some("--control") => match args.next() { - Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { - Ok(port) => out.control_port = Some(port), - Err(error) => out.errors.push(error), - }, - None => out.errors.push("--control: missing port value (expected `--control `)".to_string()), - }, - Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(token)) => out.control_token = Some(token), - Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), - None => out.errors.push("--control-token: missing token value (expected `--control-token `)".to_string()), - }, - Some("--control-token-file") => match args.next() { - Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file `)".to_string()), - }, - Some("--automation-read-root") => { - out.launch_options.push("--automation-read-root"); - match args.next() { - Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root `)".to_string()), - } - } - Some("--automation-write-root") => { - out.launch_options.push("--automation-write-root"); - match args.next() { - Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)), - None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root `)".to_string()), - } - } - Some("--safe-gpu") => { - out.launch_options.push("--safe-gpu"); - out.safe_gpu = true; - } - Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { - out.launch_options.push(match flag { - "--present-mode" => "--present-mode", - "--frame-latency" => "--frame-latency", - _ => "--stroke-trace", - }); - let flag = flag.to_string(); - match args.next().map(std::ffi::OsString::into_string) { - Some(Ok(v)) => match flag.as_str() { - "--present-mode" => out.present_mode_arg = Some(v), - "--frame-latency" => out.frame_latency_arg = Some(v), - _ => out.stroke_trace_arg = Some(v), - }, - // A bad value is reported and ignored; it never fails the launch. - _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), - } - } - Some("--in-window-menus") => { - out.launch_options.push("--in-window-menus"); - out.in_window_menus = true; - } - Some("--new-instance") => out.new_instance = true, - Some("--version") => { - out.version = true; - break; - } - // Old macOS passes a process serial number when launched from Finder. - Some(s) if s.starts_with("-psn_") => {} - // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window - // and then fail to open the flag as a file (#6431). - Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")), - Some(s) => out.files.push(s.to_owned()), - None => out.unreadable_paths.push(a.to_string_lossy().into_owned()), - } - } - out -} - -#[cfg(test)] -mod control_port_tests { - use super::{parse_control_port, usage_exit_code}; - - #[test] - fn control_port_errors_exit_non_zero() { - assert_eq!(usage_exit_code(&[]), None); - let errors = vec![parse_control_port("nope", "--control").unwrap_err()]; - assert_eq!(usage_exit_code(&errors), Some(2)); - } - - #[test] - fn control_port_accepts_valid_numbers() { - assert_eq!(parse_control_port("50494", "--control"), Ok(50494)); - assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080)); - assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0)); - } - - #[test] - fn control_port_rejects_unparseable_values_naming_them() { - // Out of range, a following flag eaten as the value, and an empty value all fail, - // naming the offending value in the message. - let err = parse_control_port("78787", "--control").unwrap_err(); - assert!(err.contains("78787"), "{err}"); - assert!(parse_control_port("--safe-gpu", "--control").is_err()); - assert!(parse_control_port("", "--control").is_err()); - } -} - -#[cfg(test)] -mod launch_args_tests { - use super::{LaunchArgs, parse_launch_args, usage_exit_code}; - use std::ffi::OsString; - - fn parse(values: &[&str]) -> LaunchArgs { - parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default()) - } - - #[test] - fn flags_without_a_value_are_usage_errors() { - // Issue #6429: a missing value must fail the launch, not silently fall back to a - // generated token or to no root. - for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] { - let parsed = parse(&[flag]); - assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors); - assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]); - assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]); - assert_eq!(usage_exit_code(&parsed.errors), Some(2)); - } - } - - #[test] - fn missing_value_does_not_clear_an_environment_default() { - // `--automation-read-root` with no value must not drop a root set through the env var. - let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() }; - let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults); - assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read"))); - assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write"))); - assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); - } - - #[test] - fn values_are_parsed_and_no_errors_remain() { - let parsed = parse(&[ - "--control", - "50494", - "--control-token", - "abcd", - "--control-token-file", - "/tmp/token", - "--automation-read-root", - "/r", - "--automation-write-root", - "/w", - "--safe-gpu", - "--in-window-menus", - "--new-instance", - "--present-mode", - "mailbox", - "photo.psd", - ]); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); - assert_eq!(parsed.control_port, Some(50494)); - assert_eq!(parsed.control_token.as_deref(), Some("abcd")); - assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token"))); - assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r"))); - assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w"))); - assert!(parsed.safe_gpu); - assert!(parsed.in_window_menus); - assert!(parsed.new_instance); - assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox")); - assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]); - assert_eq!(parsed.files, vec!["photo.psd".to_string()]); - } - - #[test] - fn version_stops_parsing() { - let parsed = parse(&["--version", "--control"]); - assert!(parsed.version); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); - } - - #[test] - fn help_stops_parsing_and_double_dash_ends_options() { - let parsed = parse(&["-h", "--safegpu", "--control"]); - assert!(parsed.help); - assert!(parsed.errors.is_empty(), "{:?}", parsed.errors); - - let parsed = parse(&["--safegpu", "--", "--help", "-x"]); - assert!(!parsed.help); - assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors); - assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]); - assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]); - } -} - fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); crash_guard::install_hook(); - // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. - photocraft_ui_egui::type_tool::warm_fonts(); - let mut launch = LaunchArgs { - automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from), - automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from), - in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"), - ..Default::default() - }; - if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) { - match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") { - Ok(port) => launch.control_port = Some(port), - Err(error) => launch.errors.push(error), + #[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 Err(e) = done { + eprintln!("PhotoCraft update helper failed: {e}"); + std::process::exit(1); } - } - launch = parse_launch_args(std::env::args_os().skip(1), launch); - if launch.help { - print_stdout(USAGE); - return Ok(()); - } - if launch.version { - print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version())); return Ok(()); } - // A malformed control port must not silently drop the control server (issue #701), a control - // or automation flag without its value must not silently fall back to a generated token or - // to no root (issue #6429), and an unknown option must not open the window (#6431): name the - // bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit - // status 2 is the usual command-line usage error, so a launcher sees the failure. - if let Some(code) = usage_exit_code(&launch.errors) { - for error in &launch.errors { - eprintln!("photocraft: {error}"); - } - eprintln!("Run `photocraft --help` for usage."); - std::process::exit(code); - } - let LaunchArgs { - control_port, - control_token, - control_token_file, - automation_read_root, - automation_write_root, - files, - unreadable_paths, + // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. + photocraft_ui_egui::type_tool::warm_fonts(); + // `--help` and `--version` print to standard output and exit 0. A usage error (an unknown + // option, a missing or bad value, a bad PHOTOCRAFT_CONTROL_PORT: issues #701, #6429, #6431) + // names the argument on standard error and exits 2, so a launcher sees the failure. Neither + // panics when the stream is closed or a pipe (`photocraft --help | head -1`). + let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit()); + let launch_args::FileNames { openable: files, unreadable: unreadable_paths } = launch.file_names(); + let launch_args::LaunchArgs { + files: _, safe_gpu, - new_instance, - mut launch_options, - present_mode_arg, - frame_latency_arg, - stroke_trace_arg, in_window_menus, - help: _, - version: _, - errors: _, + new_instance, + present_mode, + frame_latency, + stroke_trace, + control: control_port, + auth, + automation, + mut launch_options, } = launch; // The log file lives under the settings directory; opened after the arguments, so `--version` @@ -518,7 +243,7 @@ fn main() -> eframe::Result { // An adapter named in the environment is chosen at startup, which a forwarded launch never reaches. let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1; if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) { - launch_options.push("PHOTOCRAFT_GPU_ADAPTER"); + launch_options.push(launch_args::PerProcess::GpuAdapterEnv); } // One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs // become tabs in its window. Only with the per-user settings folder (a portable copy's may be @@ -526,6 +251,13 @@ fn main() -> eframe::Result { #[cfg(not(target_os = "macos"))] let single_instance = { let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok(); + // A variable documented as the equivalent of a per-launch flag counts as that flag: a + // launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it + // keeps its own process instead of handing its files over and being ignored. + // Folded in here, inside the block that only non-macOS builds compile, so the binding + // above keeps its `mut` off macOS, where nothing is folded in. + let mut launch_options = launch_options; + launch_options.extend_from_slice(&single_instance::env_launch_options()); let context = single_instance::LaunchContext { per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform, new_instance_flag: new_instance, @@ -591,8 +323,12 @@ fn main() -> eframe::Result { std::process::exit(1); } + let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref())); + if let Some(warning) = &control_pref_warning { + eprint!("{warning}"); + } let control = if let Some(port) = control_port { - let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file); + let (supplied, token_file) = (auth.control_token, auth.control_token_file); let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) { Ok(token) => token, Err(e) => { @@ -607,7 +343,7 @@ fn main() -> eframe::Result { } else { eprintln!("photocraft: using supplied control token"); } - let workspace = match photocraft_automation::AuthorizedWorkspace::new(automation_read_root.as_deref(), automation_write_root.as_deref()) { + let workspace = match automation.workspace() { Ok(workspace) => workspace, Err(error) => { eprintln!("photocraft: cannot configure automation workspace: {error}"); @@ -696,7 +432,7 @@ fn main() -> eframe::Result { options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync; } // Testers' present mode and frame latency overrides (#1343), after the preference. - let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref()); + let present = presentation::Overrides::from_args_or_env(present_mode, frame_latency); options.wgpu_options.surface = present.apply(options.wgpu_options.surface); let present_desc = presentation::describe(&options.wgpu_options.surface, &present); // Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out. @@ -740,6 +476,7 @@ fn main() -> eframe::Result { }; #[cfg(not(target_os = "linux"))] let session = Session::new(); + PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx); let mut app = PhotocraftApp::new(session, services); app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); @@ -774,7 +511,7 @@ fn main() -> eframe::Result { let info = &mut app.perf.gpu_info; info.preference = pref.name().to_string(); info.present = present_desc.clone(); - if let Some(v) = &stroke_trace_arg { + if let Some(v) = &stroke_trace { app.stroke_trace.set(v); } info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() }; @@ -786,6 +523,9 @@ fn main() -> eframe::Result { if let Some(rs) = cc.wgpu_render_state.clone() { app.perf.gpu_info.set_adapter(&rs.adapter.get_info()); app.perf.gpu_info.set_available(&rs.available_adapters); + // Every egui texture uploads through this adapter, whichever canvas draws: + // Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs). + photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info()); let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu; if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu { // Keep an earlier note, such as a requested adapter that couldn't be used. @@ -929,9 +669,10 @@ fn main() -> eframe::Result { s.finish(); } if let Ok(exe) = std::env::current_exe() { + // `--safe-gpu` first: after a `--`, it would be a file name. let launched = std::process::Command::new(exe) - .args(std::env::args_os().skip(1)) .arg("--safe-gpu") + .args(std::env::args_os().skip(1)) .env_remove("WGPU_BACKEND") .env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason) .spawn(); @@ -993,10 +734,101 @@ mod tests { egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width())); }); output.textures_delta.clear(); - let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap(); + let panel = egui::containers::panel::PanelState::load(&restored, photocraft_ui_egui::panels::panel_state_id(&restored, "dock")).unwrap(); assert_eq!(panel.size().x, 410.0); } + /// A 0.36 `ui.ron` saved panel widths under `Id::new("dock")`; the first 0.37 launch copies + /// them to the parent-scoped key, leaves the old one for a rollback, and a later resize + /// survives two more restarts. + #[test] + fn saved_036_panel_widths_migrate_and_survive_two_restarts() { + #[derive(Default)] + struct Storage(std::collections::BTreeMap); + impl eframe::Storage for Storage { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.into(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} + } + let state = |width: f32| egui::containers::panel::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 600.0)) }; + let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() }; + let draw = |ctx: &egui::Context| { + let mut output = ctx.run_ui(input(), |ui| { + egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width())); + }); + output.textures_delta.clear(); + }; + let save = |ctx: &egui::Context| { + let mut storage = Storage::default(); + ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory)); + storage + }; + let restore = |storage: &Storage| { + let ctx = egui::Context::default(); + ctx.memory_mut(|memory| *memory = eframe::get_value(storage, "egui").expect("the saved memory")); + ctx + }; + + // The real 0.14/0.15 shape: the panel state under the old key inside the egui memory. + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_persisted(egui::Id::unique("dock"), state(410.0))); + let storage = save(&ctx); + + let first = restore(&storage); + photocraft_ui_egui::panels::migrate_panel_state(&first); + draw(&first); + let key = photocraft_ui_egui::panels::panel_state_id(&first, "dock"); + assert_eq!(egui::containers::panel::PanelState::load(&first, key).map(|p| p.size().x), Some(410.0)); + assert!(egui::containers::panel::PanelState::load(&first, egui::Id::unique("dock")).is_some(), "the 0.36 key is kept"); + // Resize in 0.37: the new key wins from now on. + first.data_mut(|d| d.insert_persisted(key, state(500.0))); + let storage = save(&first); + + for restart in 0..2 { + let next = restore(&storage); + photocraft_ui_egui::panels::migrate_panel_state(&next); + draw(&next); + let key = photocraft_ui_egui::panels::panel_state_id(&next, "dock"); + let width = egui::containers::panel::PanelState::load(&next, key).map(|p| p.size().x).unwrap_or_default(); + assert!((width - 500.0).abs() < 0.5, "restart {restart}: the old 410 key must not win (got {width})"); + } + } + + /// Schema-probe `ui.ron` shapes *reconstructed* from the documented 0.14/0.15/0.37 layouts, + /// not recorded machine saves: 0.14 and 0.15 both store the flat window entry, which the + /// first 0.37 launch rewrites; a 0.37 file is left alone. Keeping all three probes makes a + /// future change to any migration rule fail here first. They do not prove eframe's real + /// `Memory`/`PanelState` deserialization. + #[test] + fn the_ui_ron_schema_probe_fixtures_migrate() { + for (name, fixture) in [("0.14", include_str!("../tests/fixtures/ui-0.14.ron")), ("0.15", include_str!("../tests/fixtures/ui-0.15.ron"))] { + let text = super::ui_state::migrate_for_test(fixture).unwrap_or_else(|e| panic!("{name}: {e}")).unwrap_or_else(|| panic!("{name}: not rewritten")); + assert!(text.contains("normal_geometry"), "{name}: the flat window entry is gone: {text}"); + assert!(!super::ui_state::saved_position_for_test(fixture), "{name}: a flat entry is not what 0.37 restores"); + } + let old = include_str!("../tests/fixtures/ui-0.14.ron"); + assert!(super::ui_state::migrate_for_test(old).expect("migratable").expect("rewritten").contains("x:1100.0"), "the 0.14 size is preserved"); + let current = include_str!("../tests/fixtures/ui-0.15.ron"); + assert!( + super::ui_state::migrate_for_test(current).expect("migratable").expect("rewritten").contains("maximized:true"), + "0.15 maximized state survives" + ); + // Already 0.37-shaped: no rewrite, and (with no saved position) the window centres. + let current = include_str!("../tests/fixtures/ui-0.37.ron"); + assert_eq!(super::ui_state::migrate_for_test(current), Ok(None)); + assert!(!super::ui_state::saved_position_for_test(current)); + // And a 0.37 file with a restorable outer position is a saved position. + let with_position = current.replace("outer_position_pixels:None", "outer_position_pixels:Some((x:2048.0,y:1228.0))"); + assert!(super::ui_state::saved_position_for_test(&with_position)); + } + #[test] fn the_window_opens_centred_at_its_default_size() { let o = super::native_options(super::CUSTOM_TITLEBAR); @@ -1065,6 +897,91 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn integrations_preferences_decide_the_control_port() { + use photocraft_engine::prefs::Integrations; + let off = Integrations { allow_agent_control: false, control_port: 0 }; + let on = Integrations { allow_agent_control: true, control_port: 0 }; + let preferred = Integrations { allow_agent_control: true, control_port: 7878 }; + + // Nothing asked for, nothing listening. + assert_eq!(super::control_port_for(None, &on), (None, None)); + // --control wins over the preference. + assert_eq!(super::control_port_for(Some(9000), &preferred), (Some(9000), None)); + // The preference opens the port when --control is absent. + assert_eq!(super::control_port_for(None, &preferred), (Some(7878), None)); + + // Allow Agent Control off refuses both, and says so when --control asked for one + // rather than ignoring the flag. + assert_eq!(super::control_port_for(None, &off), (None, None)); + let (port, warning) = super::control_port_for(Some(9000), &off); + assert_eq!(port, None); + assert!(warning.is_some_and(|w| w.contains("Allow Agent Control") && w.contains("9000")), "the refusal names the port"); + let off_with_port = Integrations { allow_agent_control: false, control_port: 7878 }; + assert_eq!(super::control_port_for(None, &off_with_port), (None, None), "off beats a preferred port"); + + // A port a hand-edited file could hold, outside 1..=65535, is refused and named. + let huge = Integrations { allow_agent_control: true, control_port: 70_000 }; + let (port, warning) = super::control_port_for(None, &huge); + assert_eq!(port, None); + assert!(warning.is_some_and(|w| w.contains("70000")), "the refusal names the value"); + } + + #[test] + fn integrations_fall_back_to_the_defaults_without_a_readable_file() { + let dir = std::env::temp_dir().join(format!("pc-integrations-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("prefs.json"); + let default = photocraft_engine::prefs::Integrations::default(); + + assert_eq!(super::integrations(None), default, "no preferences file"); + std::fs::write(&file, "not json").unwrap(); + assert_eq!(super::integrations(Some(&file)), default, "an unreadable file"); + std::fs::write(&file, r#"{"integrations":{"allowAgentControl":false,"controlPort":7878}}"#).unwrap(); + let read = super::integrations(Some(&file)); + assert!(!read.allow_agent_control); + assert_eq!(read.control_port, 7878); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The app loads preferences leniently (#6611): one wrong-typed value resets only itself. So + /// a bad value elsewhere, or in the port, must not turn Allow Agent Control back on. + #[test] + fn a_bad_value_elsewhere_keeps_agent_control_off() { + let dir = std::env::temp_dir().join(format!("pc-integrations-lenient-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("prefs.json"); + // What the Preferences dialog shows for the same file. + let shown = |text: &str| { + let mut session = photocraft_engine::Session::new(); + session.load_prefs_json(text).unwrap(); + session.prefs().integrations.clone() + }; + let read = |text: &str| { + std::fs::write(&file, text).unwrap(); + let read = super::integrations(Some(&file)); + assert_eq!(read, shown(text), "{text}"); + read + }; + + let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#); + assert!(!r.allow_agent_control, "a bad value in another section"); + assert_eq!(r.control_port, 7878); + let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#); + assert!(!r.allow_agent_control, "a bad port"); + assert_eq!(r.control_port, 0, "the bad port alone takes its default"); + // A bad Allow Agent Control alone takes its default. + let r = read(r#"{"integrations":{"allowAgentControl":"no","controlPort":7878}}"#); + assert_eq!(r, photocraft_engine::prefs::Integrations { allow_agent_control: true, control_port: 7878 }); + read(r#"{"integrations":{"allowAgentControl":false,"controlPort":-1}}"#); + read(r#"{"integrations":{"allowAgentControl":false,"controlPort":4294967296}}"#); + read(r#"{"integrations":"off"}"#); + // serde reads a struct from a list too; the app does. + assert!(!read(r#"{"integrations":[false]}"#).allow_agent_control, "a list"); + assert_eq!(read(r#"{"integrations":[false,7878]}"#).control_port, 7878); + let _ = std::fs::remove_dir_all(&dir); + } + /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. #[test] diff --git a/apps/photocraft/src/native_saveback_tests.rs b/apps/photocraft/src/native_saveback_tests.rs new file mode 100644 index 0000000000..9a08a0ae8e --- /dev/null +++ b/apps/photocraft/src/native_saveback_tests.rs @@ -0,0 +1,221 @@ +//! 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"] +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).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/portal_save.rs b/apps/photocraft/src/portal_save.rs index 42588e2bc4..d16432193b 100644 --- a/apps/photocraft/src/portal_save.rs +++ b/apps/photocraft/src/portal_save.rs @@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option { Some(PathBuf::from(std::ffi::OsString::from_vec(out))) } +/// Which file type's extensions belong on the name the dialog returned: the one it reported, or, +/// when the dialog offered only one type, that one. It reports no pick then, and the name it was +/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as +/// `Swatches.aco`. `None` when the answer names no type this dialog offered. +fn chosen_type(filters: &[Filter], picked: Option) -> Option { + match picked { + Some(i) if i < filters.len() => Some(i), + None if filters.len() == 1 => Some(0), + _ => None, + } +} + /// Save As on Linux: the portal's dialog, with the name given the extension of the file type /// picked. When that changes the name to one that already exists, the dialog opens again under /// the new name, so the portal asks before replacing it (it asked only about the typed name). @@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul loop { let Some(saved) = save_file(conn, &req)? else { return Ok(None) }; let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label)); - // With one type offered there is nothing to pick, and a typed extension is the user's. - let exts: &[String] = match picked { - Some(i) if req.filters.len() > 1 => &req.filters[i].1, - _ => &[], + // A typed extension is the user's own: `with_chosen_type` keeps one it recognises. + let exts: &[String] = match chosen_type(&req.filters, picked) { + Some(i) => &req.filters[i].1, + None => &[], }; let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts); if path == saved.path || !path.exists() { diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs index 5a839b6025..7a939639af 100644 --- a/apps/photocraft/src/portal_save/tests.rs +++ b/apps/photocraft/src/portal_save/tests.rs @@ -235,3 +235,33 @@ fn a_single_file_type_keeps_the_typed_extension() { assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase"))); daemon.kill().ok(); } + +/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and +/// that one type gives it its extension instead of a file named `Swatches` being written. +#[test] +fn a_single_file_type_gives_the_bare_name_its_extension() { + let typed: &'static str = "file:///tmp/photocraft-none/Swatches"; + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") }; + assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco"))); + daemon.kill().ok(); +} + +/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`, +/// so the rule itself is checked here too, which needs none. +#[test] +fn the_type_that_gives_the_extension_is_the_one_picked() { + let one: Vec = vec![("Color Swatches".into(), vec!["aco".into()])]; + // One type offered: the dialog reports no pick, and it was given the stem. + assert_eq!(chosen_type(&one, None), Some(0)); + assert_eq!(chosen_type(&one, Some(0)), Some(0)); + let many: Vec = request("test").filters; + assert_eq!(chosen_type(&many, Some(1)), Some(1)); + // Several types and no pick: nothing to add, as before. + assert_eq!(chosen_type(&many, None), None); + // An answer naming a type this dialog never offered is not a pick either. + assert_eq!(chosen_type(&many, Some(99)), None); +} diff --git a/apps/photocraft/src/presentation.rs b/apps/photocraft/src/presentation.rs index c6f7e4196a..ded323872b 100644 --- a/apps/photocraft/src/presentation.rs +++ b/apps/photocraft/src/presentation.rs @@ -2,8 +2,10 @@ //! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`, //! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo //! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may -//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a -//! warning in the log, never a failed start; a mode the driver may not offer is never requested. +//! queue). Unset, the platform default applies. A variable's value that isn't one of these is ignored +//! with a warning in the log, never a failed start; the `--present-mode` and `--frame-latency` flags +//! take the same values, and a bad one is a usage error (#7071). A mode the driver may not offer is +//! never requested. //! The result is logged at start and shown in the stroke trace. use eframe::egui_wgpu::SurfaceConfig; @@ -16,45 +18,54 @@ pub struct Overrides { pub latency: Option, } +/// A `--present-mode` / `PHOTOCRAFT_PRESENT_MODE` value, or `None` when it isn't one. +pub fn present_mode(v: &str) -> Option { + match v.trim().to_ascii_lowercase().as_str() { + // Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or + // FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without + // vsync, wgpu picks Immediate, else Mailbox, else Fifo. + "fifo" | "vsync" => Some(PresentMode::Fifo), + "autovsync" => Some(PresentMode::AutoVsync), + "novsync" | "immediate" | "mailbox" | "autonovsync" => Some(PresentMode::AutoNoVsync), + _ => None, + } +} + +/// A `--frame-latency` / `PHOTOCRAFT_FRAME_LATENCY` value (1 to 3), or `None` when it isn't one. +pub fn frame_latency(v: &str) -> Option { + v.trim().parse().ok().filter(|n| (1..=3).contains(n)) +} + /// Parse the two variables' values. Returns the overrides and a warning per rejected value. pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec) { let mut warnings = Vec::new(); let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| { - let m = match v.to_ascii_lowercase().as_str() { - // Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or - // FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without - // vsync, wgpu picks Immediate, else Mailbox, else Fifo. - "fifo" | "vsync" => PresentMode::Fifo, - "autovsync" => PresentMode::AutoVsync, - "novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync, - _ => { - warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored")); - return None; - } - }; - Some(m) - }); - let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::() { - Ok(n @ 1..=3) => Some(n), - _ => { - warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored")); - None + let m = present_mode(v); + if m.is_none() { + warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored")); } + m + }); + let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| { + let n = frame_latency(v); + if n.is_none() { + warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored")); + } + n }); (Overrides { mode, latency }, warnings) } impl Overrides { - /// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` / - /// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged. - pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self { - let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok(); - let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok(); - let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref())); + /// `--present-mode` / `--frame-latency` when given (checked by the command line), else + /// `PHOTOCRAFT_PRESENT_MODE` / `PHOTOCRAFT_FRAME_LATENCY`, whose rejected values are logged. + pub fn from_args_or_env(mode: Option, latency: Option) -> Self { + let env = |given: bool, name: &str| if given { None } else { std::env::var(name).ok() }; + let (o, warnings) = parse(env(mode.is_some(), "PHOTOCRAFT_PRESENT_MODE").as_deref(), env(latency.is_some(), "PHOTOCRAFT_FRAME_LATENCY").as_deref()); for w in warnings { log::warn!("{w}"); } - o + Self { mode: mode.or(o.mode), latency: latency.or(o.latency) } } /// `surface` with the overrides applied. diff --git a/apps/photocraft/src/screen_color.rs b/apps/photocraft/src/screen_color.rs index 8a0d4ca901..ec8cd6378c 100644 --- a/apps/photocraft/src/screen_color.rs +++ b/apps/photocraft/src/screen_color.rs @@ -1,6 +1,11 @@ //! Desktop colour capture starts only on an explicit UI request. No snapshots are written to //! disk, transmitted or retained after a pick. Wayland delegates to its permission portal. -use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service}; +use photocraft_ui_egui::screen_picker::{Capture, Pending, Service}; +// Only the platform capture backends (and the tests) build images and check sizes. +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] +use photocraft_ui_egui::screen_picker::MAX_PIXELS; +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +use photocraft_ui_egui::screen_picker::ScreenImage; pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option { if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) { @@ -34,6 +39,7 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option Option { placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index) } +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))] fn count(width: usize, height: usize) -> Result { width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into()) } diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f7f9972f6b..71e7f0ffcd 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -1,10 +1,8 @@ //! Native platform services: file dialogs (rfd), filesystem, codecs. -use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS}; -use photocraft_color::{ColorMode, SampleType}; -use photocraft_doc::{Document, Layer, Size}; +use photocraft_codecs::{ChannelLayout, EncodeOptions, Image}; +use photocraft_doc::Document; use photocraft_format::RecoveryStore; -use photocraft_geom::Rect; use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services}; use std::cell::{Cell, RefCell}; use std::future::Future; @@ -17,12 +15,13 @@ use std::sync::Arc; mod recovery_tests; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and -/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets +/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), swatches (.aco, .act, .ase) and keyboard shortcut sets /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", - "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", - "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", + "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act", + "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -67,9 +66,12 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[ ("Radiance HDR", &["hdr"]), ("FITS", &["fits", "fit", "fts"]), ("DPX and Cineon", &["dpx", "cin"]), + ("Amiga IFF", &["iff", "lbm", "ilbm"]), + ("DICOM", &["dcm", "dicom", "dc3"]), ("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]), ("Brushes", &["abr"]), ("Gradients", &["grd"]), + ("Styles", &["asl"]), ("Swatches", &["aco", "act", "ase"]), ("Keyboard Shortcuts", &["kys"]), ]; @@ -102,6 +104,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("DPX", &["dpx"]), // Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327). ("DDS", &["dds"]), + ("Cineon", &["cin"]), + ("Amiga IFF", &["iff"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog @@ -198,10 +202,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, }; if multiple { let picked = dialog.pick_files(); - Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) }) + Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) }) } else { let picked = dialog.pick_file(); - Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) }) + Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) }) } } FileDialogRequest::Folder { initial_dir } => { @@ -209,7 +213,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, dialog = dialog.set_directory(dir); } let picked = dialog.pick_folder(); - Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) }) + Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) }) } FileDialogRequest::Save { suggested } => { let filters = save_filters(&suggested); @@ -244,10 +248,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, .map_err(|e| e.to_string()) .and_then(|conn| crate::portal_save::save_as(&conn, req)); match portal { - Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())), + Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)), Err(e) => { log::info!("portal save dialog unavailable ({e}), using rfd"); - dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) + dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) } } }) @@ -256,7 +260,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, { let _ = filters; let picked = dialog.save_file(); - Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) }) + Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) }) } } }; @@ -270,8 +274,22 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, } } -fn path_of(file: &rfd::FileHandle) -> String { - file.path().to_string_lossy().into_owned() +/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid +/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths +/// are to be written to. +fn answer<'a>(paths: impl IntoIterator, saving: bool, make: impl FnOnce(Vec) -> FileDialogAnswer) -> FileDialogAnswer { + match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() { + Ok(paths) => make(paths), + Err(e) => FileDialogAnswer::Unavailable(e), + } +} + +/// [`answer`] for a dialog that picks one path. +fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer { + match photocraft_ui_egui::file_open::path_text(path, saving) { + Ok(path) => make(path), + Err(e) => FileDialogAnswer::Unavailable(e), + } } /// Runs `future` to completion on this thread, sleeping until it is woken. @@ -330,6 +348,44 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string()) } +/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path +/// on Windows (where it holds the name's letter case as stored). False when either can't be read. +fn same_file(a: &Path, b: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + #[cfg(not(unix))] + { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } +} + +/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file +/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a +/// file system that ignores letter case is the same entry; a hard link is another entry, which a +/// save replaces while the first name keeps the old file. False when the folder can't be read. +fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool { + let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf(); + if !same_file(a, b) || !same_file(&folder(a), &folder(b)) { + return false; + } + let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false }; + if x == y { + return true; + } + let Ok(entries) = std::fs::read_dir(folder(a)) else { return false }; + let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect(); + !(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y)) +} + fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { settings.io_options() } @@ -416,6 +472,17 @@ fn recovery_services(dir: Option) -> Services { } } +/// The OS clipboard read and write run under the crash guard, like Open and Export (#7582): a +/// panic in arboard, or in decoding a copied file, becomes the paste's (or copy's) error and the +/// app keeps its documents. +fn guarded_clipboard_get(mut read: impl FnMut() -> Result)>, String> + 'static) -> photocraft_ui_egui::ClipboardGetFn { + Box::new(move || crate::crash_guard::guard("Paste", &mut read)) +} + +fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn { + Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px))) +} + /// The pixels to paste when the clipboard holds copied files rather than an image (Copy in /// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes, /// upright, as RGBA8. Files that aren't images are skipped after reading only their header. @@ -495,6 +562,12 @@ 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: the sandbox has no network (see the +/// manifest's finish-args) and `flatpak update` owns updates there. +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(); @@ -528,9 +601,9 @@ pub fn native(automation: Option) -> }) as photocraft_ui_egui::AutomationCommandFn }); Services { - import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| { + import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { crate::crash_guard::guard("Open", || { - photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) }) })), export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { @@ -545,6 +618,7 @@ pub fn native(automation: Option) -> let meta = std::fs::metadata(path).ok()?; Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) })), + same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))), automation_read, automation_write, automation_open_pcraft, @@ -558,9 +632,10 @@ 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()), clipboard_set_image: Some({ let clip = clip.clone(); - Box::new(move |w: u32, h: u32, px: &[u8]| { + guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| { let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?; let cb = match slot.as_mut() { Some(c) => c, @@ -573,7 +648,7 @@ pub fn native(automation: Option) -> clipboard_get_image: Some({ let clip = clip.clone(); let unreadable = clip_unreadable.clone(); - Box::new(move || { + guarded_clipboard_get(move || { let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?; let cb = match slot.as_mut() { Some(c) => c, @@ -637,77 +712,19 @@ fn platform_beep() { { let _ = winsafe::MessageBeep(winsafe::co::MBP::OK); } - #[cfg(not(target_os = "windows"))] + // The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal + // bell below is silent for an app started from the Finder or the Dock. + #[cfg(target_os = "macos")] + objc2_app_kit::NSBeep(); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - // No beep API among this app's other platforms' dependencies: write the terminal bell - // (audible when launched from a terminal, which is the same cue macOS terminals use). + // No beep API among the other platforms' dependencies: write the terminal bell (audible + // when launched from a terminal). use std::io::Write as _; let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush()); } } -/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io). -#[allow(dead_code)] -pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?; - let (w, h) = (img.width(), img.height()); - let depth = match img.sample_type() { - CS::U8 => SampleType::U8, - CS::U16 => SampleType::U16, - _ => SampleType::F32, - }; - let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA); - let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA); - let mode = if gray { - ColorMode::Grayscale - } else if cmyk { - ColorMode::Cmyk - } else { - ColorMode::Rgb - }; - let target = match mode { - ColorMode::Grayscale => ChannelLayout::GrayA, - ColorMode::Cmyk => ChannelLayout::CmykA, - _ => ChannelLayout::Rgba, - }; - let sample = match depth { - SampleType::U8 => CS::U8, - SampleType::U16 => CS::U16, - SampleType::F32 => CS::F32, - }; - let conv = img.convert(target, sample); - let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string()); - let mut doc = Document::new(stem, Size::new(w, h), mode, depth); - doc.icc_profile = img.icc.clone().map(std::sync::Arc::new); - if let Some((x, _)) = img.meta.dpi { - doc.resolution_dpi = x; - } - let mut layer = Layer::raster("Background", doc.pixel_format()); - let data = conv.to_normalized(); - layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data); - doc.layers.push(layer); - Ok(doc) -} - -#[allow(dead_code)] -pub fn export_flat(doc: &Document, path: &str) -> Result, String> { - let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?; - let buf = photocraft_compose::flatten(doc); - let (w, h) = (buf.rect.width(), buf.rect.height()); - let data: Vec = buf.px.iter().flat_map(|p| *p).collect(); - let img = match doc.depth { - SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels), - SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::>()), - SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data), - } - .map_err(|e| e.to_string())?; - let img = match &doc.icc_profile { - Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())), - _ => img, - }; - photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -716,6 +733,31 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + /// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily + /// (Open would fail with "No such file", Save would write a second, renamed file). + #[test] + fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() { + #[cfg(unix)] + let bad = { + use std::os::unix::ffi::OsStrExt; + std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png")) + }; + #[cfg(windows)] + let bad = { + use std::os::windows::ffi::OsStringExt; + let mut wide: Vec = "img".encode_utf16().collect(); + wide.push(0xD800); + wide.extend(".png".encode_utf16()); + std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide)) + }; + let good = std::env::temp_dir().join("ok.png"); + let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo); + assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name"))); + assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap())); + let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths); + assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again"))); + } + /// #6121: the file type picked in the save dialog gives the name its extension where the /// dialog left the one it opened with, and only there. #[test] @@ -871,6 +913,12 @@ mod tests { ); } + #[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()); + } + #[test] fn xwayland_command_matches_how_photocraft_was_installed() { // Flatpak: WAYLAND_DISPLAY doesn't reach the sandbox's socket choice; flatpak's flags do. @@ -1022,6 +1070,50 @@ mod tests { assert_eq!(dialog_file_name("", true), None); } + /// A name in another letter case reaches the same file where the file system ignores case + /// (Windows, and macOS by default), and only there. Two files with the same contents are two. + #[test] + fn same_file_follows_the_file_system() { + let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD")); + std::fs::write(&cat, b"same").unwrap(); + std::fs::write(&dog, b"same").unwrap(); + assert!(same_file(&cat, &cat)); + assert!(!same_file(&cat, &dog)); + assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored"); + assert!(!same_file(&dir.join("missing.psd"), &cat)); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// A hard link is another entry in the folder, though it reaches the same file: a save to it + /// replaces that entry and leaves the other name on the old file. Another spelling of a name, + /// where letter case is ignored, is the same entry. + #[test] + fn a_hard_link_is_another_entry() { + let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id())); + std::fs::create_dir_all(dir.join("other")).unwrap(); + let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd")); + std::fs::write(&cat, b"cat").unwrap(); + std::fs::hard_link(&cat, &bare).unwrap(); + std::fs::hard_link(&cat, &other).unwrap(); + // Where both names reach one file (one inode on Unix), the folder still lists both. + let one_file = |_: &Path, _: &Path| true; + assert!(!same_entry(&bare, &cat, one_file)); + assert!(!same_entry(&bare, &cat, same_file)); + assert!(!same_entry(&other, &cat, same_file), "another folder"); + assert!(same_entry(&cat, &cat, one_file)); + assert!(same_entry(&cat, &cat, same_file)); + if upper.exists() { + assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored"); + } else { + std::fs::hard_link(&cat, &upper).unwrap(); + assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart"); + } + assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); @@ -1299,4 +1391,29 @@ mod tests { assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255]))); assert!(image_from_files(&[bin]).is_none()); } + + /// #7582: a panic while reading the OS clipboard (in arboard, or decoding a copied file) is + /// the paste's error in the status bar, not a crash: the document stays and the next paste + /// works. The same for a panic while writing it. + #[test] + fn a_panicking_clipboard_read_is_reported_not_a_crash() { + let panics = Rc::new(Cell::new(true)); + let p = panics.clone(); + let get = guarded_clipboard_get(move || if p.get() { panic!("clipboard decoder bug") } else { Ok(Some((2, 2, [0u8, 0, 255, 255].repeat(4)))) }); + let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(get), ..Default::default() }); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let layers = app.session.active().unwrap().doc.layers.len(); + + let error = app.run("edit.paste", json!({})).unwrap_err(); + assert!(error.contains("Paste failed"), "{error}"); + assert!(app.ui.status_error && app.ui.status.contains("Paste failed"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "the document is unchanged"); + + panics.set(false); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the next paste works"); + + let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug")); + assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed")); + } } diff --git a/apps/photocraft/src/services/recovery_tests.rs b/apps/photocraft/src/services/recovery_tests.rs index b1bd2d51c0..23191ed7f9 100644 --- a/apps/photocraft/src/services/recovery_tests.rs +++ b/apps/photocraft/src/services/recovery_tests.rs @@ -1,5 +1,6 @@ use super::*; -use photocraft_color::Color; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::Size; use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery}; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs index 4b57fa6942..b193cade68 100644 --- a/apps/photocraft/src/single_instance.rs +++ b/apps/photocraft/src/single_instance.rs @@ -41,6 +41,8 @@ use photocraft_automation::security::{LineRead, generate_token, read_bounded_lin use photocraft_ui_egui::{OsEvent, OsEventsFn}; use serde_json::{Value, json}; +use crate::launch_args::PerProcess; + const LOCK_FILE: &str = "instance.lock"; const INFO_FILE: &str = "instance.json"; /// The Unix socket a sandboxed primary listens on, in the app's runtime directory. @@ -129,8 +131,54 @@ pub struct LaunchContext<'a> { pub control: bool, /// Paths that aren't valid Unicode: this launch reports them. pub unreadable_paths: bool, - /// Options that only apply to the process they're given to (`--safe-gpu`, …). - pub launch_options: &'a [&'static str], + /// Options that only apply to the process they're given to (`--safe-gpu`, …), including the + /// ones this launch's environment sets (see [`env_launch_options`]). + pub launch_options: &'a [PerProcess], +} + +/// Variables documented as the equivalent of a per-launch flag, each with the option it stands for +/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for +/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this +/// process alone. These are read where they take effect, not by the command line; the variables +/// that are a flag's fallback (`PHOTOCRAFT_IN_WINDOW_MENUS`, the automation roots) are counted by +/// [`crate::launch_args::parse`], as clap reads them. +const ENV_LAUNCH_OPTIONS: [(&str, PerProcess, ValueRule); 3] = [ + ("PHOTOCRAFT_PRESENT_MODE", PerProcess::PresentMode, present_mode_is_on), + ("PHOTOCRAFT_FRAME_LATENCY", PerProcess::FrameLatency, frame_latency_is_on), + ("PHOTOCRAFT_STROKE_TRACE", PerProcess::StrokeTrace, stroke_trace_is_on), +]; + +/// Whether a variable's value asks this launch for its own option. Each rule matches how that +/// variable is read where it takes effect, so a value ignored there keeps no process here either. +type ValueRule = fn(&str) -> bool; + +/// A value `presentation::parse` accepts, handed over as written: it trims, treats empty as unset and +/// warns and ignores anything it does not recognise, so an unrecognised `PHOTOCRAFT_PRESENT_MODE` +/// changes nothing there and so needs no process of its own here. +fn present_mode_is_on(value: &str) -> bool { + crate::presentation::parse(Some(value), None).0.mode.is_some() +} + +/// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`. +fn frame_latency_is_on(value: &str) -> bool { + crate::presentation::parse(None, Some(value)).0.latency.is_some() +} + +/// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the +/// rest is a stderr summary or a CSV path. It trims, so `" 0 "` is off there and off here. +fn stroke_trace_is_on(value: &str) -> bool { + !matches!(value.trim(), "" | "0" | "off" | "false") +} + +/// The per-process options this launch's environment sets. A launch that sets only variables would +/// otherwise hand its files to the running instance, which never sees the override, and exit. +pub fn env_launch_options() -> Vec { + env_launch_options_from(|name| std::env::var_os(name)) +} + +/// [`env_launch_options`] with the reader given, so the rule is testable without the environment. +fn env_launch_options_from(get: impl Fn(&str) -> Option) -> Vec { + ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, option, _)| *option).collect() } /// Why this launch runs as its own instance, or `None` to take part in single-instance. @@ -148,7 +196,8 @@ pub fn skip_reason(c: &LaunchContext) -> Option { return Some("some paths aren't valid Unicode".into()); } if !c.launch_options.is_empty() { - return Some(format!("{} only applies to a new process", c.launch_options.join(", "))); + let options: Vec = c.launch_options.iter().map(ToString::to_string).collect(); + return Some(format!("{} only applies to a new process", options.join(", "))); } None } @@ -837,8 +886,8 @@ mod tests { assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None); assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some()); assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some()); - let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base }); - assert!(reason.is_some_and(|r| r.contains("--safe-gpu"))); + let reason = skip_reason(&LaunchContext { launch_options: &[PerProcess::SafeGpu, PerProcess::GpuAdapterEnv], ..base }); + assert_eq!(reason.as_deref(), Some("--safe-gpu, PHOTOCRAFT_GPU_ADAPTER only applies to a new process")); } /// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime @@ -915,4 +964,41 @@ mod tests { assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None); assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None); } + + /// A variable documented as the equivalent of a per-launch flag counts as that flag: a launch + /// that sets only variables has per-process options, so it keeps its own process rather than + /// handing its files to the running instance, which never sees the override. + #[test] + fn an_environment_override_counts_as_a_process_option() { + let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into())); + assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec![PerProcess::PresentMode]); + assert_eq!( + with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_FRAME_LATENCY", "2")]), + vec![PerProcess::FrameLatency, PerProcess::StrokeTrace] + ); + // Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way + // it is read where it takes effect, not by one rule shared across all three. + assert!(with(&[]).is_empty()); + assert!(with(&[("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty()); + // The trace is off at `off` and `false` as well, and a value the presentation parser rejects + // is ignored there, so neither keeps a process of its own here. + assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty()); + // The trace and the two presentation values are trimmed where they take effect. + assert_eq!( + with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), + vec![PerProcess::PresentMode, PerProcess::StrokeTrace] + ); + // The menus and the automation roots are flags' fallbacks: `launch_args::parse` counts them. + assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "1"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]).is_empty()); + let base = LaunchContext { + per_user_settings: true, + new_instance_flag: false, + new_instance_env: None, + control: false, + unreadable_paths: false, + launch_options: &[], + }; + let reason = skip_reason(&LaunchContext { launch_options: &with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), ..base }); + assert!(reason.as_deref().is_some_and(|r| r.contains("--present-mode")), "{reason:?}"); + } } diff --git a/apps/photocraft/src/ui_state.rs b/apps/photocraft/src/ui_state.rs index c4de67a994..b356fb26c2 100644 --- a/apps/photocraft/src/ui_state.rs +++ b/apps/photocraft/src/ui_state.rs @@ -6,16 +6,32 @@ //! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file //! holding such values (the window and panels open at their defaults; nothing else is lost). //! A file that isn't RON at all is left alone: eframe already ignores it. +//! +//! egui 0.37 also replaced the flat `WindowSettings` (`inner_position_pixels`, +//! `inner_size_points: Option`, …) with `normal_geometry: { outer_position_pixels, +//! scale_factor, inner_size_points }`. eframe reads the file before any application code, so an +//! old value cannot be interpreted lazily: [`sanitize`] rewrites it before eframe loads it and +//! keeps a one-time `ui.ron.pre-0.37` backup. The old panel keys in the egui memory part are +//! untouched (`panels::panel_state_id` migrates them additively), but this window rewrite is +//! one-way: after it, a 0.36 build ignores the window entry and opens at its default geometry. +//! Restoring the backup is the documented rollback. -use std::path::Path; +use std::{collections::HashMap, path::Path}; /// Window positions, sizes and panel rects beyond this many points or pixels are corrupt. const MAX_COORD: f64 = 1.0e6; /// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375, /// 300% on a 1× display is 3). const ZOOM: std::ops::RangeInclusive = 0.1..=10.0; +/// The size [`crate::native_options`] opens a window with when an old save has no usable size. +const DEFAULT_SIZE: f32 = 1440.0; +const DEFAULT_HEIGHT: f32 = 900.0; +/// Kept beside `ui.ron` before the one-way 0.36 → 0.37 window rewrite. +const BACKUP_SUFFIX: &str = "pre-0.37"; -/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged. +/// Remove `path` if it holds state that would crash startup, and migrate an old window entry to +/// the 0.37 schema before eframe reads it. Never fails: errors are logged and leave the file +/// alone (eframe then ignores what it cannot read). pub fn sanitize(path: Option<&Path>) { let Some(path) = path else { return }; let Ok(text) = std::fs::read_to_string(path) else { return }; @@ -24,30 +40,72 @@ pub fn sanitize(path: Option<&Path>) { if let Err(e) = std::fs::remove_file(path) { log::warn!("could not remove {}: {e}", path.display()); } + return; + } + match migrate_window(text) { + Ok(Some(text)) => write_migrated(path, &text), + Ok(None) => {} + Err(why) => log::warn!("not rewriting the saved window geometry in {}: {why}", path.display()), } } -/// Whether `path` holds a window position from an earlier session, which eframe restores. Then -/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry, +/// Write the migrated file, keeping a one-time backup. A failed backup aborts the rewrite: the +/// pre-0.37 window geometry is the documented rollback, so never destroy the only copy. +fn write_migrated(path: &Path, text: &str) { + let backup = backup_path(path); + if !backup.exists() + && let Err(e) = std::fs::copy(path, &backup) + { + log::warn!("could not back up {} to {}: {e}; leaving it unchanged", path.display(), backup.display()); + return; + } + if let Err(e) = std::fs::write(path, text) { + log::warn!("could not write the migrated window layout to {}: {e}", path.display()); + } +} + +fn backup_path(path: &Path) -> std::path::PathBuf { + let mut name = path.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| "ui.ron".into()); + name.push(format!(".{BACKUP_SUFFIX}")); + path.with_file_name(name) +} + +/// Whether `path` holds a window position that eframe restores, which means +/// `NativeOptions::centered` must be off: eframe applies `centered` after the restored geometry, /// so it moved the window back to the centre of the main monitor on every launch (#4826). +/// +/// Only the 0.37 `normal_geometry.outer_position_pixels` counts: `egui_winit::WindowGeometry` +/// sets it and `initialize_viewport_builder` applies it. A 0.36 flat entry is not restored by +/// 0.37 (`sanitize` rewrites it first), and its macOS `inner_position_pixels` was never an outer +/// position. pub fn has_saved_position(path: Option<&Path>) -> bool { let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false }; saved_position(&text) } fn saved_position(text: &str) -> bool { - let Ok(kv) = ron::from_str::>(text) else { return false }; + let Ok(kv) = ron::from_str::>(text) else { return false }; let Some(window) = kv.get("window").and_then(|w| ron::from_str::(w).ok()) else { return false }; - // eframe restores the inner position on macOS and the outer position elsewhere - // (`egui_winit::WindowSettings::initialize_viewport_builder`). - let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" }; - matches!(field(&window, key), Some(ron::Value::Option(Some(_)))) + let Some(geometry) = field(&window, "normal_geometry") else { return false }; + matches!(field(geometry, "outer_position_pixels"), Some(ron::Value::Option(Some(_)))) +} + +/// The 0.36 → 0.37 rewrite of one `ui.ron` text, for the recorded 0.14/0.15 fixtures. +#[cfg(test)] +pub(crate) fn migrate_for_test(text: &str) -> Result, String> { + migrate_window(text.to_owned()) +} + +/// [`saved_position`] for the recorded fixtures. +#[cfg(test)] +pub(crate) fn saved_position_for_test(text: &str) -> bool { + saved_position(text) } /// `Err` names the first value that would crash startup. fn check(text: &str) -> Result<(), String> { // eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe. - let Ok(kv) = ron::from_str::>(text) else { return Ok(()) }; + let Ok(kv) = ron::from_str::>(text) else { return Ok(()) }; for (key, value) in &kv { // eframe also skips an entry that doesn't parse. let Ok(v) = ron::from_str::(value) else { continue }; @@ -60,6 +118,108 @@ fn check(text: &str) -> Result<(), String> { Ok(()) } +/// `Some(new_text)` when `text` is an old-schema file that should be rewritten; `None` when it +/// is already 0.37-shaped or holds no window entry; `Err` when the old window entry is unusable +/// and must be left untouched. +fn migrate_window(text: String) -> Result, String> { + let Ok(mut kv) = ron::from_str::>(&text) else { return Ok(None) }; + let Some(value) = kv.get("window").cloned() else { return Ok(None) }; + let Ok(window) = ron::from_str::(&value) else { return Ok(None) }; + if field(&window, "normal_geometry").is_some() { + return Ok(None); + } + if field(&window, "inner_size_points").is_none() && field(&window, "inner_position_pixels").is_none() && field(&window, "outer_position_pixels").is_none() { + return Ok(None); + } + let migrated = migrate_window_value(&window)?; + kv.insert("window".into(), migrated); + ron::to_string(&kv).map(Some).map_err(|e| format!("cannot write the new window entry: {e}")) +} + +/// The RON payload eframe 0.37 deserializes as `egui_winit::WindowSettings`. +/// +/// The field order and names mirror `WindowGeometry`; the types are local because +/// `egui_winit::WindowGeometry` keeps its fields private. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)] +struct PointV37 { + x: f32, + y: f32, +} + +impl PointV37 { + fn sane(self) -> Option { + let ok = |v: f32| v.is_finite() && (v.abs() as f64) <= MAX_COORD; + (ok(self.x) && ok(self.y)).then_some(self) + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)] +struct GeometryV37 { + outer_position_pixels: Option, + scale_factor: f32, + inner_size_points: PointV37, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)] +struct WindowV37 { + normal_geometry: GeometryV37, + fullscreen: bool, + maximized: bool, +} + +fn migrate_window_value(window: &ron::Value) -> Result { + let size = old_size(window)?; + // The old macOS entry stored an inner (not outer) position in logical points and Linux saved + // no scale factor, so a faithful restore is only possible for the Windows physical outer + // position. Everywhere else the window opens centred with its saved size. + let position = if cfg!(target_os = "windows") { old_position(window, "outer_position_pixels") } else { None }; + let window = WindowV37 { + normal_geometry: GeometryV37 { + outer_position_pixels: position, + // Physical pixels of the old Windows position: 0.37 divides by zoom × scale. + scale_factor: 1.0, + inner_size_points: size, + }, + fullscreen: old_bool(window, "fullscreen"), + maximized: old_bool(window, "maximized"), + }; + ron::to_string(&window).map_err(|e| format!("cannot serialize the new window entry: {e}")) +} + +fn old_size(window: &ron::Value) -> Result { + let value = field(window, "inner_size_points"); + let Some(ron::Value::Option(Some(size))) = value else { + // A missing size is the app's own opening size, not corrupt data. + return Ok(PointV37 { x: DEFAULT_SIZE, y: DEFAULT_HEIGHT }); + }; + let point = point_value(size).ok_or_else(|| "inner_size_points is not a finite (x, y) point".to_string())?; + let point = point.sane().ok_or_else(|| "inner_size_points is not finite or is absurdly large".to_string())?; + if point.x < 1.0 || point.y < 1.0 { + return Err("inner_size_points has a zero or negative side".into()); + } + Ok(point) +} + +/// One dimension of an old `Option` field, rejecting corrupt values rather than guessing. +fn old_position(window: &ron::Value, key: &str) -> Option { + match field(window, key) { + Some(ron::Value::Option(Some(p))) => point_value(p).and_then(PointV37::sane), + // `None`, missing, and malformed all mean "no position"; corrupt positions elsewhere are + // caught by `check`, which removes a file with non-finite values before this runs. + _ => None, + } +} + +fn old_bool(window: &ron::Value, key: &str) -> bool { + matches!(field(window, key), Some(ron::Value::Bool(true))) +} + +fn point_value(v: &ron::Value) -> Option { + let x = field(v, "x").and_then(as_f32)?; + let y = field(v, "y").and_then(as_f32)?; + Some(PointV37 { x, y }) +} + /// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted). fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> { if depth > 64 { @@ -86,6 +246,13 @@ fn as_f64(v: &ron::Value) -> Option { } } +fn as_f32(v: &ron::Value) -> Option { + as_f64(v).and_then(|f| { + let f = f as f32; + f.is_finite().then_some(f) + }) +} + fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> { match v { ron::Value::Map(m) => m.get(&ron::Value::String(name.into())), @@ -95,7 +262,8 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> { #[cfg(test)] mod tests { - use super::{check, saved_position}; + use super::{DEFAULT_HEIGHT, DEFAULT_SIZE, WindowV37, backup_path, check, migrate_window, saved_position}; + use std::{collections::HashMap, path::PathBuf}; fn file(window: &str, zoom: &str) -> String { let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))"); @@ -103,6 +271,17 @@ mod tests { } const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))"; + const WINDOW_WITH_OUTER: &str = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; + + /// Read the window payload back as eframe 0.37 would. + fn window_of(text: &str) -> WindowV37 { + let kv: HashMap = ron::from_str(text).expect("the migrated file is a RON map"); + ron::from_str(kv.get("window").expect("a window entry")).expect("0.37 WindowSettings shape") + } + + fn migrated(window: &str) -> String { + migrate_window(file(window, "1.0")).expect("migratable").expect("rewritten") + } #[test] fn a_saved_layout_is_kept() { @@ -122,13 +301,83 @@ mod tests { } } + #[test] + fn an_old_size_and_maximized_state_survive() { + let text = migrated(WINDOW_WITH_OUTER); + let window = window_of(&text); + assert_eq!(window.normal_geometry.inner_size_points.x, 880.0); + assert_eq!(window.normal_geometry.inner_size_points.y, 480.0); + assert!(!window.fullscreen); + assert!(!window.maximized); + let maximized = migrated(&WINDOW.replace("fullscreen:false", "fullscreen:true,maximized:true")); + assert!(window_of(&maximized).fullscreen && window_of(&maximized).maximized); + } + + #[test] + fn a_missing_size_uses_the_default_opening_size() { + // A window entry with a position but no size migrates and keeps the app's opening size. + let window = window_of(&migrated("(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,maximized:false)")); + assert_eq!(window.normal_geometry.inner_size_points.x, DEFAULT_SIZE); + assert_eq!(window.normal_geometry.inner_size_points.y, DEFAULT_HEIGHT); + // A window entry with no geometry fields at all is left alone: it is not a 0.36 window. + assert_eq!(migrate_window(file("(fullscreen:false,maximized:false)", "1.0")), Ok(None)); + // So is a file with no window-like fields at all. + assert_eq!(migrate_window(file("(something:1)", "1.0")), Ok(None)); + } + + #[test] + fn the_old_position_is_kept_only_on_windows() { + let text = migrated(WINDOW_WITH_OUTER); + let pos = window_of(&text).normal_geometry.outer_position_pixels; + if cfg!(target_os = "windows") { + let pos = pos.expect("the Windows physical outer position survives"); + assert_eq!((pos.x, pos.y), (2048.0, 1228.0)); + assert_eq!(window_of(&text).normal_geometry.scale_factor, 1.0); + } else { + assert!(pos.is_none(), "an old macOS/Linux position is not a restorable outer position"); + } + } + + #[test] + fn a_corrupt_old_size_is_not_rewritten() { + let bad = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:0.0)"); + assert!(migrate_window(file(&bad, "1.0")).is_err()); + let huge = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:9000000.0)"); + assert!(migrate_window(file(&huge, "1.0")).is_err()); + } + + #[test] + fn an_already_migrated_file_is_left_alone() { + let text = migrated(WINDOW); + assert_eq!(migrate_window(text), Ok(None)); + // A 0.36 build accepts the entry, but its missing flat geometry defaults to None. + // It cannot recover position or size from the nested 0.37 geometry. + let kv: HashMap = ron::from_str(&migrated(WINDOW)).unwrap(); + let old: OldWindow = ron::from_str(kv.get("window").unwrap()).unwrap(); + assert!(old.inner_position_pixels.is_none() && old.outer_position_pixels.is_none()); + assert!(old.inner_size_points.is_none(), "documented rollback loss of saved geometry"); + // Restoring the original backup restores both the old position and size. + let restored: OldWindow = ron::from_str(WINDOW).unwrap(); + let position = restored.inner_position_pixels.unwrap(); + let size = restored.inner_size_points.unwrap(); + assert_eq!((position.x, position.y), (616.0, 430.0)); + assert_eq!((size.x, size.y), (1100.0, 760.0)); + } + #[test] fn a_saved_window_position_is_detected() { - // #4826: the position from the report (Linux Mint, window moved to the third monitor). - let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; - assert!(saved_position(&file(window, "1.0"))); - let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; + // A 0.36 flat entry is not what eframe 0.37 restores (it would open at the OS default); + // `sanitize` rewrites it before eframe runs, and the migrated nested value is detected. + assert!(!saved_position(&file(WINDOW_WITH_OUTER, "1.0")), "an unmigrated flat entry is not a restorable position"); + // An old macOS inner position is not an outer position either. + assert!(!saved_position(&file(WINDOW, "1.0"))); + let no_position = + "(normal_geometry:(outer_position_pixels:None,scale_factor:1.0,inner_size_points:(x:880.0,y:480.0)),fullscreen:false,maximized:false)"; assert!(!saved_position(&file(no_position, "1.0"))); + // The migrated nested geometry is what a 0.37 save holds (on Windows only, from the + // migration; a later 0.37 save on macOS/Linux has one too and eframe restores it). + let text = migrated(WINDOW_WITH_OUTER); + assert_eq!(saved_position(&text), cfg!(target_os = "windows")); assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry"); for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] { assert!(!saved_position(text), "{text:?}"); @@ -148,7 +397,7 @@ mod tests { } #[test] - fn sanitize_removes_only_a_bad_file() { + fn sanitize_removes_only_a_bad_file_and_migrates_a_good_one() { let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let good = dir.join("good.ron"); @@ -159,7 +408,58 @@ mod tests { super::sanitize(Some(&bad)); super::sanitize(Some(&dir.join("missing.ron"))); super::sanitize(None); - assert!(good.exists() && !bad.exists()); + assert!(!bad.exists()); + assert!(good.exists()); + // Migrated in place, with the one-time pre-0.37 backup beside it. + let text = std::fs::read_to_string(&good).unwrap(); + assert!(window_of(&text).normal_geometry.inner_size_points.x == 1100.0); + let backup = backup_path(&good); + assert!(backup.exists(), "the pre-0.37 layout is kept for rollback"); + assert_eq!(std::fs::read_to_string(&backup).unwrap(), file(WINDOW, "1.0")); + // A second run leaves both untouched. + super::sanitize(Some(&good)); + assert_eq!(std::fs::read_to_string(&good).unwrap(), text); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn the_backup_is_not_overwritten_by_a_later_migration() { + let dir = std::env::temp_dir().join(format!("photocraft-ui-state-backup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("ui.ron"); + std::fs::write(&path, file(WINDOW, "1.0")).unwrap(); + super::sanitize(Some(&path)); + let backup = backup_path(&path); + let original = std::fs::read_to_string(&backup).unwrap(); + // Pretend a 0.36 run wrote its old schema again; the backup must stay the first one. + std::fs::write(&path, file(&WINDOW.replace("1100.0", "1111.0"), "1.0")).unwrap(); + super::sanitize(Some(&path)); + assert_eq!(std::fs::read_to_string(&backup).unwrap(), original); + assert!(std::fs::read_to_string(&path).unwrap().contains("1111")); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The 0.36 flat schema, kept to prove the new nested value is a one-way migration. + #[derive(Default, serde::Deserialize)] + #[serde(default)] + #[allow(dead_code)] + struct OldWindow { + inner_position_pixels: Option, + outer_position_pixels: Option, + fullscreen: bool, + maximized: bool, + inner_size_points: Option, + } + + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct Point { + x: f32, + y: f32, + } + + #[test] + fn the_backup_path_sits_beside_ui_ron() { + assert_eq!(backup_path(&PathBuf::from("/tmp/x/ui.ron")), PathBuf::from("/tmp/x/ui.ron.pre-0.37")); + } } diff --git a/apps/photocraft/src/updates.rs b/apps/photocraft/src/updates.rs new file mode 100644 index 0000000000..40ac9ae016 --- /dev/null +++ b/apps/photocraft/src/updates.rs @@ -0,0 +1,27 @@ +//! Release checks through `craft-update`, the library every Craft App shares. Only public release +//! metadata is requested. + +use photocraft_engine::updates_cmds::{CheckFn, CheckResult, Release}; + +pub fn service() -> CheckFn { + Box::new(|| { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("update check".into()) + .spawn(move || { + let result = check(); + if let Err(e) = &result { + log::warn!("Update check: {e}"); + } + let _ = tx.send(result); + }) + .map_err(|e| format!("Could not start update check: {e}"))?; + Ok(rx) + }) +} + +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)?; + 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 index 3e086617b3..e096fb2301 100644 --- a/apps/photocraft/src/vector_conversion.rs +++ b/apps/photocraft/src/vector_conversion.rs @@ -1,4 +1,4 @@ -//! Bounded, private VectorCraft conversion before an embedded AI/PDF/EPS editing session. +//! 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; @@ -9,15 +9,43 @@ use std::process::{Command, Stdio}; use std::sync::mpsc::Receiver; use std::time::{Duration, Instant}; -/// Prepare a bounded SVG working copy, converting native vectors inside private MCP roots. +/// Validate the source using a private preview, then expose only an original-format copy. pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> 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 initial = if source.extension == "ai" { run_export(editor, source, stop, "ai")? } else { source.bytes.as_ref().clone() }; + if source.extension == "ai" { + 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()); } - let owned = OwnedSvg::create(&source.bytes)?; - if source.extension == "svg" { - return Ok((owned, source.bytes.as_ref().clone())); + 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 @@ -31,7 +59,7 @@ pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Recei 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 = conversion_commands(&input, &owned.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"); @@ -82,31 +110,40 @@ pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Recei 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()?)?; - let svg = source_svg(&converted, source.size)?; - // Validate bounded rendering before the interactive editor sees the working copy. - photocraft_io::svg::import_svg_bounded("Artwork.svg", &svg, 24_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?; - owned.overwrite(&svg)?; - Ok(svg) + 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.map(|svg| (owned, svg)) + 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": "svg"}))]) + 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("Artwork.svg", bytes, 24_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?; + 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" { @@ -118,7 +155,13 @@ fn source_svg(bytes: &[u8], size: Option<(f64, f64)>) -> Result, String> 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()); } - let dimension = |key| root.attribute(key).and_then(|v| v.trim_end_matches("pt").parse::().ok()).filter(|v| v.is_finite() && *v > 0.0); + // 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 = @@ -161,6 +204,7 @@ mod tests { 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] @@ -170,12 +214,39 @@ mod tests { 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()); + } + #[test] fn conversion_requires_successful_open_and_export_replies() { let good = r#"{"id":1,"result":{"isError":false}} @@ -207,7 +278,11 @@ mod live_vectorcraft_tests { 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, svg) = prepare(editor, &source, &stop).unwrap(); + let (owned, initial) = prepare(editor, &source, &stop).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)); diff --git a/apps/photocraft/tests/command_line_usage.rs b/apps/photocraft/tests/command_line_usage.rs deleted file mode 100644 index c4c745ea9a..0000000000 --- a/apps/photocraft/tests/command_line_usage.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! `photocraft --help` and unknown options must not open the window (#6431): before the fix, -//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as -//! `--safegpu` launched the GUI and logged "Couldn't open --help". -//! -//! These run the real binary, and every case must exit before the window opens: Windows and -//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out. -//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the -//! other usage errors, so stderr shows whether those arguments were taken as unknown options. - -/// Run `photocraft` with `args` in an isolated config dir; return the exit -/// status, stdout and stderr. -fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) { - static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft")) - .args(args) - .env("PHOTOCRAFT_CONFIG_DIR", &dir) - .env_remove("PHOTOCRAFT_CONTROL_PORT") - .env_remove("DISPLAY") - .env_remove("WAYLAND_DISPLAY") - .env_remove("WAYLAND_SOCKET") - .output() - .expect("failed to run photocraft binary"); - std::fs::remove_dir_all(&dir).ok(); - (output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned()) -} - -#[test] -fn help_prints_usage_and_exits_0() { - for flag in ["--help", "-h"] { - let (status, stdout, stderr) = run_photocraft(&[flag]); - assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}"); - assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}"); - for option in ["--safe-gpu", "--new-instance", "--control ", "--version", "--"] { - assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}"); - } - assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}"); - } -} - -#[test] -fn help_wins_over_later_arguments() { - let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]); - assert_eq!(status.code(), Some(0)); - assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}"); -} - -#[test] -fn unknown_options_exit_2_naming_them() { - for flag in ["--safegpu", "-x", "--help=yes"] { - let (status, stdout, stderr) = run_photocraft(&[flag]); - assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}"); - assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}"); - assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}"); - assert!(stdout.is_empty(), "stdout: {stdout}"); - } -} - -#[test] -fn unknown_option_errors_are_reported_with_control_port_errors() { - let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]); - assert_eq!(status.code(), Some(2), "stderr: {stderr}"); - assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); - assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}"); -} - -#[test] -fn arguments_after_double_dash_are_files() { - // `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage - // nor is an unknown option; only the bad port is reported. - let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]); - assert_eq!(status.code(), Some(2), "stderr: {stderr}"); - assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); - assert!(!stdout.contains("Usage:"), "stdout: {stdout}"); - assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); -} - -#[test] -fn macos_process_serial_number_is_still_ignored() { - // Old macOS passes `-psn_` when launching from Finder; it is neither a file nor an error. - let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]); - assert_eq!(status.code(), Some(2), "stderr: {stderr}"); - assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); - assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); -} - -#[test] -fn new_instance_is_a_known_option() { - let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]); - assert_eq!(status.code(), Some(2), "stderr: {stderr}"); - assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); - assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); -} diff --git a/apps/photocraft/tests/fixtures/README.md b/apps/photocraft/tests/fixtures/README.md new file mode 100644 index 0000000000..1eb90452f0 --- /dev/null +++ b/apps/photocraft/tests/fixtures/README.md @@ -0,0 +1,7 @@ +These files are **schema probes**, not recorded machine saves. + +`ui-0.14.ron`, `ui-0.15.ron` and `ui-0.37.ron` are minimal payloads reconstructed from the +documented eframe/egui layouts. They exercise `apps/photocraft/src/ui_state.rs` (the flat +window entry versus the nested `normal_geometry` entry) only. They do not prove that eframe +and egui deserialize a real `Memory` or `PanelState`; a captured 0.14/0.15/0.37 `ui.ron` +would be needed for that, with the machine-specific data reviewed first. diff --git a/apps/photocraft/tests/fixtures/ui-0.14.ron b/apps/photocraft/tests/fixtures/ui-0.14.ron new file mode 100644 index 0000000000..619999cf2b --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.14.ron @@ -0,0 +1 @@ +{"window":"(inner_position_pixels:Some((x:616.0,y:430.0)),outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:1100.0,y:760.0)))","egui":"()"} diff --git a/apps/photocraft/tests/fixtures/ui-0.15.ron b/apps/photocraft/tests/fixtures/ui-0.15.ron new file mode 100644 index 0000000000..7edccd600b --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.15.ron @@ -0,0 +1 @@ +{"window":"(inner_position_pixels:None,outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:true,inner_size_points:Some((x:1440.0,y:900.0)))","egui":"()"} diff --git a/apps/photocraft/tests/fixtures/ui-0.37.ron b/apps/photocraft/tests/fixtures/ui-0.37.ron new file mode 100644 index 0000000000..d5973c734f --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.37.ron @@ -0,0 +1 @@ +{"window":"(normal_geometry:(outer_position_pixels:None,scale_factor:2.0,inner_size_points:(x:1440.0,y:900.0)),fullscreen:false,maximized:false)","egui":"()"} diff --git a/apps/photocraft/tests/it/command_line_usage.rs b/apps/photocraft/tests/it/command_line_usage.rs new file mode 100644 index 0000000000..a0d8377638 --- /dev/null +++ b/apps/photocraft/tests/it/command_line_usage.rs @@ -0,0 +1,157 @@ +//! `photocraft --help` and unknown options must not open the window (#6431): before the fix, +//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as +//! `--safegpu` launched the GUI and logged "Couldn't open --help". +//! +//! These run the real binary, and every case must exit before the window opens: Windows and +//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out. +//! Cases whose arguments are valid add a usage error before them: `--control-token` with +//! `--control-token-file` is only checked once every argument is read (and only with `--control`), +//! so stderr shows how the other arguments were taken. + +/// The variables the command line reads: a test that wants one sets it. +const VARS: [&str; 6] = [ + "PHOTOCRAFT_CONTROL_PORT", + "PHOTOCRAFT_CONTROL_TOKEN", + "PHOTOCRAFT_CONTROL_TOKEN_FILE", + "PHOTOCRAFT_AUTOMATION_READ_ROOT", + "PHOTOCRAFT_AUTOMATION_WRITE_ROOT", + "PHOTOCRAFT_IN_WINDOW_MENUS", +]; + +/// A usage error found only after every argument is read. +const LATE_ERROR: [&str; 6] = ["--control", "0", "--control-token", "abcd", "--control-token-file", "token"]; + +/// How a run of `photocraft` ended. +struct Run { + status: std::process::ExitStatus, + stdout: String, + stderr: String, +} + +/// Run `photocraft` with `args` and `vars` in an isolated config dir. +fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut cmd = std::process::Command::new(crate::binary()); + for var in VARS { + cmd.env_remove(var); + } + let output = cmd + .args(args) + .envs(vars.iter().copied()) + .env("PHOTOCRAFT_CONFIG_DIR", &dir) + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + .output() + .expect("failed to run photocraft binary"); + std::fs::remove_dir_all(&dir).ok(); + Run { status: output.status, stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned() } +} + +fn run_photocraft(args: &[&str]) -> Run { + run_with(args, &[]) +} + +/// [`LATE_ERROR`] then `args`: exit 2, and the stderr. +fn late_error(args: &[&str], vars: &[(&str, &str)]) -> String { + let Run { status, stdout, stderr } = run_with(&[&LATE_ERROR[..], args].concat(), vars); + assert_eq!(status.code(), Some(2), "{args:?}: {stderr}"); + assert!(stderr.contains("cannot be used with"), "{args:?}: {stderr}"); + assert!(stdout.is_empty(), "{args:?}: {stdout}"); + stderr +} + +#[test] +fn help_prints_usage_and_exits_0() { + for flag in ["--help", "-h"] { + let Run { status, stdout, stderr } = run_photocraft(&[flag]); + assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}"); + assert!(stdout.contains("Usage: photocraft"), "{flag} stdout: {stdout}"); + for option in ["--safe-gpu", "--new-instance", "--control ", "--version", "`--`", "PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_NEW_INSTANCE"] { + assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}"); + } + assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}"); + } +} + +#[test] +fn help_wins_over_later_arguments() { + let Run { status, stdout, .. } = run_photocraft(&["--help", "picture.psd", "--safegpu"]); + assert_eq!(status.code(), Some(0)); + assert!(stdout.contains("Usage: photocraft"), "stdout: {stdout}"); +} + +#[test] +fn version_prints_the_long_version() { + let Run { status, stdout, .. } = run_photocraft(&["--version", "--safegpu"]); + assert_eq!(status.code(), Some(0)); + assert_eq!(stdout, format!("photocraft {}\n", photocraft_engine::build_info::long_version())); +} + +#[test] +fn unknown_options_exit_2_naming_them() { + for (args, named) in [ + (&["--safegpu"][..], "unexpected argument '--safegpu'"), + (&["-x"][..], "unexpected argument '-x'"), + (&["--help=yes"][..], "unexpected value 'yes' for '--help'"), + // An unknown flag after a bad value is reported first: clap drops the pending value's error. + (&["--control", "nope", "--safegpu"][..], "unexpected argument '--safegpu'"), + (&["--control", "nope"][..], "invalid value 'nope' for '--control '"), + ] { + let Run { status, stdout, stderr } = run_photocraft(args); + assert_eq!(status.code(), Some(2), "{args:?} must be a usage error, stderr: {stderr}"); + assert!(stderr.contains(named), "stderr: {stderr}"); + assert!(stderr.contains("--help"), "stderr should point at --help: {stderr}"); + assert!(stdout.is_empty(), "stdout: {stdout}"); + } +} + +#[test] +fn arguments_after_double_dash_are_files() { + // `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage + // nor is an unknown option; only the late error is reported. + let stderr = late_error(&["--", "--help", "-x"], &[]); + assert!(!stderr.contains("unexpected"), "stderr: {stderr}"); +} + +#[test] +fn macos_process_serial_number_is_still_ignored() { + // Old macOS passes `-psn_` when launching from Finder; it is neither a file nor an error. + let stderr = late_error(&["-psn_0_12345"], &[]); + assert!(!stderr.contains("unexpected"), "stderr: {stderr}"); +} + +#[test] +fn new_instance_is_a_known_option() { + let stderr = late_error(&["--new-instance"], &[]); + assert!(!stderr.contains("unexpected"), "stderr: {stderr}"); +} + +/// The variables stand in for their flags: a flag wins, an empty variable counts as unset, a bad +/// value is a usage error naming the variable, and a flag without its value is still an error +/// when its variable is set (#6429). +#[test] +fn variables_are_the_flags_fallbacks() { + let Run { status, stderr, .. } = run_with(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("invalid value 'nope' for 'PHOTOCRAFT_CONTROL_PORT'"), "stderr: {stderr}"); + // With the flag (in LATE_ERROR), the variable is never read. + let stderr = late_error(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]); + assert!(!stderr.contains("PHOTOCRAFT_CONTROL_PORT"), "stderr: {stderr}"); + // The variable turns the control server on as the flag does, so the token pair is checked. + let Run { status, stderr, .. } = run_with(&LATE_ERROR[2..], &[("PHOTOCRAFT_CONTROL_PORT", "0")]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("cannot be used with"), "stderr: {stderr}"); + let Run { status, stderr, .. } = run_with(&["--automation-read-root"], &[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "/env/read")]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("a value is required for '--automation-read-root '"), "stderr: {stderr}"); + // The other half of the token pair from a variable is the same either/or error, naming it + // but never showing the token. + let token = "a".repeat(64); + let Run { status, stderr, .. } = run_with(&["--control", "0", "--control-token-file", "token"], &[("PHOTOCRAFT_CONTROL_TOKEN", token.as_str())]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("PHOTOCRAFT_CONTROL_TOKEN") && stderr.contains("cannot be used with") && !stderr.contains(&token), "stderr: {stderr}"); +} diff --git a/apps/photocraft/tests/control_exit_code.rs b/apps/photocraft/tests/it/control_exit_code.rs similarity index 62% rename from apps/photocraft/tests/control_exit_code.rs rename to apps/photocraft/tests/it/control_exit_code.rs index 280afac261..4be624c5d2 100644 --- a/apps/photocraft/tests/control_exit_code.rs +++ b/apps/photocraft/tests/it/control_exit_code.rs @@ -9,15 +9,26 @@ /// workspace check instead of failing on token validation first. const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +/// How a run of `photocraft` ended. +struct Run { + status: std::process::ExitStatus, + stderr: String, +} + /// Run `photocraft` with the given arguments in an isolated config dir and -/// return the exit status and stderr. Each call gets its own config dir (tests run in +/// return how it ended. Each call gets its own config dir (tests run in /// parallel), and no display variables, so a regression fails fast instead of opening a window. -fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) { +fn run_photocraft(args: &[&str]) -> Run { + run_with(args, &[]) +} + +/// [`run_photocraft`], with `vars` set. +fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft")) + let output = std::process::Command::new(crate::binary()) .args(args) .env("PHOTOCRAFT_CONFIG_DIR", &dir) // Isolate from the host environment so the test is reproducible. @@ -29,37 +40,40 @@ fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) { .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") .env_remove("WAYLAND_SOCKET") + .envs(vars.iter().copied()) .output() .expect("failed to run photocraft binary"); std::fs::remove_dir_all(&dir).ok(); - (output.status, String::from_utf8_lossy(&output.stderr).into_owned()) + Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() } } #[test] fn invalid_control_token_exits_with_status_2() { // `validate_token` rejects a token that is not exactly 64 hex characters. - let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]); + let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", "invalid"]); assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}"); assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}"); } #[test] fn both_token_and_token_file_exits_with_status_2() { - // `server_token` rejects the combination of a supplied token and a token file. + // The command line rejects a supplied token together with a token file (#7071), before + // `server_token` would. let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let token_file = dir.join("token.txt"); std::fs::write(&token_file, VALID_TOKEN).unwrap(); - let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]); + let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]); std::fs::remove_dir_all(&dir).ok(); assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}"); - assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}"); + assert!(stderr.contains("cannot be used with"), "stderr: {stderr}"); } #[test] fn missing_automation_read_root_exits_with_status_2() { // `AuthorizedWorkspace::new` fails when the read root does not exist. - let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]); + let Run { status, stderr } = + run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]); assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}"); assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}"); } @@ -67,7 +81,17 @@ fn missing_automation_read_root_exits_with_status_2() { #[test] fn missing_automation_write_root_exits_with_status_2() { // `AuthorizedWorkspace::new` fails when the write root does not exist. - let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]); + let Run { status, stderr } = + run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]); assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}"); assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}"); } + +#[test] +fn automation_roots_come_from_the_environment_too() { + for var in ["PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_AUTOMATION_WRITE_ROOT"] { + let Run { status, stderr } = run_with(&["--control", "0", "--control-token", VALID_TOKEN], &[(var, "/nonexistent/photocraft-test-path")]); + assert_eq!(status.code(), Some(2), "{var}: missing root must exit 2, stderr: {stderr}"); + assert!(stderr.contains("cannot configure automation workspace"), "{var}: stderr: {stderr}"); + } +} diff --git a/apps/photocraft/tests/it/main.rs b/apps/photocraft/tests/it/main.rs new file mode 100644 index 0000000000..73bea9196a --- /dev/null +++ b/apps/photocraft/tests/it/main.rs @@ -0,0 +1,10 @@ +//! The crate's integration tests, linked into one test binary. + +mod command_line_usage; +mod control_exit_code; +mod startup_without_display; + +/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_photocraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_photocraft").into()) +} diff --git a/apps/photocraft/tests/startup_without_display.rs b/apps/photocraft/tests/it/startup_without_display.rs similarity index 96% rename from apps/photocraft/tests/startup_without_display.rs rename to apps/photocraft/tests/it/startup_without_display.rs index 5ffb5783a9..b0faf77d5b 100644 --- a/apps/photocraft/tests/startup_without_display.rs +++ b/apps/photocraft/tests/it/startup_without_display.rs @@ -30,7 +30,7 @@ fn missing_display_preserves_existing_gpu_crash_evidence() { } fn launch_without_display(dir: &std::path::Path) -> String { - let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft")) + let output = std::process::Command::new(crate::binary()) .env("PHOTOCRAFT_CONFIG_DIR", dir) .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") diff --git a/assets/icons/align-edge-bottom.svg b/assets/icons/align-edge-bottom.svg new file mode 100644 index 0000000000..e2993c8305 --- /dev/null +++ b/assets/icons/align-edge-bottom.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-hcenter.svg b/assets/icons/align-edge-hcenter.svg new file mode 100644 index 0000000000..264b4fe9bb --- /dev/null +++ b/assets/icons/align-edge-hcenter.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-left.svg b/assets/icons/align-edge-left.svg new file mode 100644 index 0000000000..192310fd3f --- /dev/null +++ b/assets/icons/align-edge-left.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-right.svg b/assets/icons/align-edge-right.svg new file mode 100644 index 0000000000..0386cf7475 --- /dev/null +++ b/assets/icons/align-edge-right.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-top.svg b/assets/icons/align-edge-top.svg new file mode 100644 index 0000000000..54d4bdce21 --- /dev/null +++ b/assets/icons/align-edge-top.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/align-edge-vcenter.svg b/assets/icons/align-edge-vcenter.svg new file mode 100644 index 0000000000..6df5d35096 --- /dev/null +++ b/assets/icons/align-edge-vcenter.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/distribute-bottom.svg b/assets/icons/distribute-bottom.svg new file mode 100644 index 0000000000..20d3b31c33 --- /dev/null +++ b/assets/icons/distribute-bottom.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-hcenter.svg b/assets/icons/distribute-hcenter.svg new file mode 100644 index 0000000000..34faee88f4 --- /dev/null +++ b/assets/icons/distribute-hcenter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/assets/icons/distribute-horizontal.svg b/assets/icons/distribute-horizontal.svg new file mode 100644 index 0000000000..fef7592891 --- /dev/null +++ b/assets/icons/distribute-horizontal.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/distribute-left.svg b/assets/icons/distribute-left.svg new file mode 100644 index 0000000000..075305c0c0 --- /dev/null +++ b/assets/icons/distribute-left.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-right.svg b/assets/icons/distribute-right.svg new file mode 100644 index 0000000000..659d58fbee --- /dev/null +++ b/assets/icons/distribute-right.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-top.svg b/assets/icons/distribute-top.svg new file mode 100644 index 0000000000..1f02d74c9b --- /dev/null +++ b/assets/icons/distribute-top.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/distribute-vcenter.svg b/assets/icons/distribute-vcenter.svg new file mode 100644 index 0000000000..4d1734a083 --- /dev/null +++ b/assets/icons/distribute-vcenter.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/assets/icons/distribute-vertical.svg b/assets/icons/distribute-vertical.svg new file mode 100644 index 0000000000..fdc7c6abd2 --- /dev/null +++ b/assets/icons/distribute-vertical.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/text-warp.svg b/assets/icons/text-warp.svg new file mode 100644 index 0000000000..03f30ed87b --- /dev/null +++ b/assets/icons/text-warp.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 05a7c3da27..6137b12466 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -63,4 +63,23 @@ layers because that would lose their group's blending semantics. Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment. -CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters. +CLI parsing checks the flags and their values before any work starts. A flag the subcommand doesn't take (a typo such as `--fromat`), a flag given twice (only `--cmd` and `--params` may repeat), a value out of range (`--quality 0`), malformed `--new` or `--params` JSON, `` together with `--new`, and `--control-token` together with `--control-token-file` are all usage errors (exit status 2), rather than being ignored or failing later. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. A path that is not valid Unicode reaches the command instead of being a usage error: `convert` reads and writes it, while `info`, `run` and `batch` open files through the automation layer, which still needs UTF-8 paths and fails with exit status 1 (`droplet` takes its arguments as text, so it refuses such a path as a usage error). `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand. + +`mcp` and `serve` reject positional arguments, empty values and options for the wrong mode before +starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port` (or +`PHOTOCRAFT_CONTROL_PORT`, below). Configure automation roots on the desktop process when using +bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md) for examples. + +`mcp` and `serve` fall back to environment variables: `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` for the token flags, and `PHOTOCRAFT_CONTROL_PORT` for `serve --port` and `mcp --bridge` (which then bridges to `127.0.0.1:`). The automation roots are flags only: the desktop app's `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` grant the CLI nothing. A flag wins over its variable, an empty variable counts as unset, and a bad value is a usage error naming the variable. When `PHOTOCRAFT_CONTROL_PORT` switches `serve` to TCP or `mcp` to bridging, the command says so on standard error. A token together with a token file is a usage error only where a token is read (`serve` on TCP, `mcp` bridging), so a stale exported pair doesn't stop stdio `serve` or headless `mcp`. An automation root given to `mcp` while the variable would switch it to bridging is a usage error too: the roots are the headless engine's, and the bridge would drop them. ` --help` lists each variable (unless it is set but empty), never with the token's value. A variable holds a port, so `PHOTOCRAFT_CONTROL_PORT=127.0.0.1:7878` is refused; only `--bridge` takes `host:port`. + +This checking is not a security policy layer. Engine commands remain responsible for validating their own parameters. + +### Shell completions and man pages + +The Linux `.deb` and `.rpm` packages install `man photocraft-cli` (plus a page per subcommand, such as `man photocraft-cli-convert`) and completions for bash, zsh and fish (zsh's in `/usr/share/zsh/site-functions`, which the `.deb` also links from Debian's `/usr/share/zsh/vendor-completions`); the Linux tarball and the FreeBSD tarball carry the same files, the FreeBSD one under `man/man1` and `share/`. The pages list each flag's environment variable, as `--help` does. Without a package, generate them from a source checkout: + +```sh +cargo run -p photocraft-cli --example man-and-completions -- out/ +``` + +`out/` then holds `photocraft-cli.bash`, `_photocraft-cli` (zsh), `photocraft-cli.fish`, `photocraft-cli.elv` (elvish), `_photocraft-cli.ps1` (PowerShell) and the `.1` man pages. For example, copy `photocraft-cli.bash` to `~/.local/share/bash-completion/completions/photocraft-cli`, `photocraft-cli.fish` into `~/.config/fish/completions/`, `_photocraft-cli` into a directory on zsh's `$fpath`, and read a page with `man -l out/photocraft-cli.1`. The desktop app `photocraft` has neither. diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md index 2c36b464c2..e9e9521ea6 100644 --- a/book/src/automation/mcp.md +++ b/book/src/automation/mcp.md @@ -20,6 +20,16 @@ Bridge to a running desktop application with the same private token file used by photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file /private/path/control.token ``` +Use absolute root paths in MCP client configurations, since a relative root is resolved from the +client's launch directory. In bridge mode the roots are configured on the desktop app, and root +flags on `photocraft-cli mcp --bridge` are rejected. Explicit `--control-token` and +`--control-token-file` flags require `--bridge` (or `PHOTOCRAFT_CONTROL_PORT`) and are mutually +exclusive; bridge credentials can also come from `PHOTOCRAFT_CONTROL_TOKEN` or +`PHOTOCRAFT_CONTROL_TOKEN_FILE`. Each launch option +except `--help`/`-h` takes one non-empty value and cannot be repeated. Stray positional arguments, +missing values and options for the wrong mode are usage errors (exit 2) reported on stderr before +the server starts. + Tools cover session/document operations, command discovery and execution, batching, and—when bridged—UI inspection and control. Tool schemas improve correctness but are not authorization boundaries. ## From an installed release diff --git a/book/src/development/release-security.md b/book/src/development/release-security.md index 742ab11095..99a95f1f82 100644 --- a/book/src/development/release-security.md +++ b/book/src/development/release-security.md @@ -6,6 +6,7 @@ PhotoCraft's release workflow builds platform packages and a draft GitHub Releas - platform-specific signing/notarization steps exist for macOS and Windows; - signing secrets are scoped through the GitHub `release` environment; +- the Azure Trusted Signing client that Windows signing loads is pinned to a version and SHA-256, and refused on a mismatch; - release artifacts include `SHA256SUMS.txt`; - the final release job receives `contents: write`, while build jobs use narrower access; - artifacts remain in a draft release until a maintainer publishes them. diff --git a/book/src/formats/pcraft.md b/book/src/formats/pcraft.md index 39385877a1..40ecbdd127 100644 --- a/book/src/formats/pcraft.md +++ b/book/src/formats/pcraft.md @@ -8,8 +8,11 @@ tiles/.zst blobs/.zst thumb.png composite/preview.png +save.lock ``` +`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it. + The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly. ## Implemented load limits diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index c333cbc737..e1a2cc23fa 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -4,7 +4,7 @@ ## 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: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. 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`. +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`. ## WebP @@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink. - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. +- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered. - DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level. Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through diff --git a/contributors/people.toml b/contributors/people.toml index 0441d6396c..04b0f2f235 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,13 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.brianblakely] +sync_github_name = true + +[people.abdul-karim-mia] +real_name = "Abdul karim mia" +sync_github_name = false + [people.jonathanwxh-cell] display_name = "jonathnwxh" sync_github_name = false @@ -72,6 +79,9 @@ sync_github_name = true [people.maxm11] sync_github_name = true +[people.mweinbach] +sync_github_name = true + [people.skraus2] sync_github_name = true @@ -116,6 +126,9 @@ sync_github_name = false [people.axlecoffee] sync_github_name = true +[people.zi-ger] +sync_github_name = true + [people.Losminek] sync_github_name = true @@ -166,6 +179,9 @@ sync_github_name = true real_name = "Sidney Markowitz" sync_github_name = true +[people.theoabw] +real_name = "Theo Wilenius" + [people.TaylorBurke] sync_github_name = true @@ -194,3 +210,78 @@ real_name = "Don Omondi" [people.ha1des] sync_github_name = true + +[people.webdevtodayjason] +display_name = "Jason Brashear" + +[people.AndreeSalazar] +real_name = "Andreé Salazar" +sync_github_name = true + +[people.LorenzoFasolino] +real_name = "Lorenzo Fasolino" +display_name = "Lorenzo Fasolino" +sync_github_name = false + +[people.zolyy] +real_name = "Gall Zoltan" +sync_github_name = false + +[people.theshubh77] +real_name = "Shubham Bhamare" +sync_github_name = true + +[people.ksaxena149] +sync_github_name = true + +[people.giang17] +real_name = "Giang Nguyen" +sync_github_name = true + +[people.aqibmohd271] +sync_github_name = true + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false + +[people.Gotix-eg] +real_name = "Ahmed Tammam" +display_name = "Gotix" +sync_github_name = true + +[people.adi-1108] +real_name = "Aditya Vishwakarma" +sync_github_name = true + +[people.benandez] +real_name = "Martin Ball" + +[people.ronibalthazar] +sync_github_name = true + +[people.batuhangozegu] +real_name = "Batuhan Gözegü" +sync_github_name = false + +[people.drunkeninja42] +real_name = "Kushagra Sharma" + +[people.WebWork-rrs] +display_name = "Ratul Paul" + +[people.softwaregames1] +sync_github_name = true + +[people.VoiceLessQ] +display_name = "VoiceLessQ" +sync_github_name = false + +[people.erenova] +display_name = "Erenova" +sync_github_name = false + +[people.jasallen] +real_name = "Jason DeBoever" +sync_github_name = true diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 535c3736c6..1be7fd954e 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -108,11 +108,11 @@ The same reader is shared, as an independent copy, with VectorCraft's `vectorcra * Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries, checksums, budgets, cycles, hostile streams, every truncation) and property tests of random - mutations, here and in `photocraft-io`'s `tests/affinity.rs`. + mutations, here and in `photocraft-io`'s `tests/it/affinity.rs`. * `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art) - (CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs` - parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and + (CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/it/real_files.rs` + parses them; `photocraft-io`'s `tests/it/affinity_corpus.rs` imports and flattens each one and compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a ceiling per file). * `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`) diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 880b7236d1..76ffb69f7d 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -15,6 +15,11 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; /// A save appends one table; real files have one to a few hundred. const MAX_TABLES: usize = 4096; +/// Distinct ids the allocation tables may name; each costs a map slot even when only deleted. +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; /// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. const MAX_ZSTD_WINDOW: u64 = 64 << 20; @@ -164,8 +169,9 @@ impl<'a> Archive<'a> { pub fn open(bytes: &'a [u8], limits: Limits) -> Result { let header = header(bytes)?; // Newest record per id: (table date, table index, record). - let mut latest: HashMap)> = HashMap::new(); + let mut latest: HashMap>)> = HashMap::new(); let mut names: HashMap = HashMap::new(); + let mut name_bytes = 0usize; let mut seen = HashSet::new(); let mut next = header.fat_offset; let mut tables = 0usize; @@ -222,19 +228,31 @@ impl<'a> Archive<'a> { } else { stored }; - record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression }); + record = Some(Box::new(Entry { id, name: String::new(), offset, size, compressed, crc, compression })); } 2 => {} _ => return Err(Error::Malformed("unknown allocation record")), } if flag == 0 { let len = usize::from(c.u16()?); - let name = String::from_utf8_lossy(c.take(len)?).into_owned(); - names.entry(id).or_insert(name); + let raw = c.take(len)?; + // Only the first name per id is kept: charge and copy just that one. + if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) { + let name = String::from_utf8_lossy(raw).into_owned(); + name_bytes = name_bytes.saturating_add(name.len()); + if name_bytes > MAX_NAME_BYTES { + return Err(Error::Limit("archive entry names too large")); + } + slot.insert(name); + } } // Equal dates: the first table in the chain (the newest) wins, as in afread. let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); if replace { + // Deletion records are five bytes each: cap what distinct ids may retain. + if latest.len() >= MAX_ENTRIES && !latest.contains_key(&id) { + return Err(Error::Limit("too many archive entries")); + } latest.insert(id, (date, tables, record)); } } @@ -248,7 +266,7 @@ impl<'a> Archive<'a> { for (id, (_, _, record)) in latest { if let (Some(mut e), Some(name)) = (record, names.get(&id)) { e.name = name.clone(); - entries.insert(e.name.clone(), e); + entries.insert(e.name.clone(), *e); } } Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits }) @@ -278,6 +296,9 @@ impl<'a> Archive<'a> { if total > self.limits.max_total { return Err(Error::Limit("document too large")); } + // Charge before decoding: a corrupt entry that fails its checksum has already cost the + // decompression, and an importer that recovers must not get it again for free. + self.extracted = total; let mut c = Cursor::new(self.bytes, offset(e.offset)?); if c.take(4)? != b"#Fil" { return Err(Error::Malformed("archive entry has no data block")); @@ -297,7 +318,6 @@ impl<'a> Archive<'a> { if crc32fast::hash(&out) != e.crc { return Err(Error::Malformed("archive entry checksum mismatch")); } - self.extracted = total; Ok(out) } } diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 6825abd511..fa45cf6213 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -9,17 +9,21 @@ const MAX_NODES: usize = 4_000_000; const MAX_COMPOUND_DEPTH: usize = 64; const MAX_OUTLINE_VISITS: usize = 500_000; const MAX_OUTLINE_POINTS: usize = 4_000_000; +/// Curve records decoded across the document. A shared curve is decoded again for each +/// occurrence, and records that only move handles add no points. +const MAX_OUTLINE_RECORDS: usize = 8_000_000; /// Geometry is expanded per occurrence, since parametric outlines can depend on their world /// transform. Bound work across the entire document instead of memoizing by object id alone. pub(crate) struct Budget { visits_left: usize, points_left: usize, + records_left: usize, } impl Default for Budget { fn default() -> Self { - Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS } + Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS, records_left: MAX_OUTLINE_RECORDS } } } @@ -30,6 +34,12 @@ impl Budget { Ok(()) } + /// Charge curve records before they are decoded. + fn records(&mut self, n: usize) -> Result<(), &'static str> { + self.records_left = self.records_left.checked_sub(n).ok_or("curve geometry budget exhausted")?; + Ok(()) + } + /// Charge both leaf geometry and each copy into a compound before allocating that copy. fn path(&mut self, path: &Path) -> Result<(), &'static str> { let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3))); @@ -138,7 +148,21 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option { Value::UInt(n) => usize::try_from(*n).ok()?, _ => return None, }; - let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect())); + let corners = match s.obj(crvs, b"CnrD") { + Some(c) => { + 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()) + { + r.warn(reason); + return None; + } + corner_radii(fields) + } + None => None, + }; let mut path = Path::default(); let mut seen = 0usize; for index in 0..count { @@ -152,6 +176,10 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option { r.warn("curves with more than four million nodes"); return None; } + if let Err(reason) = r.geometry_budget.records(records.len()) { + r.warn(reason); + return None; + } let anchors = anchors(records)?; let radii = if index == 0 { corners.as_ref() } else { None }; if let Some(sp) = subpath(&anchors, closed, radii) { @@ -258,3 +286,15 @@ fn corner_radii(fields: Vec<&Value>) -> Option> { .collect(), ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn curve_records_are_charged_across_the_document() { + let mut b = Budget::default(); + assert!(b.records(MAX_OUTLINE_RECORDS).is_ok()); + assert_eq!(b.records(1), Err("curve geometry budget exhausted")); + } +} diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index f77c970ae2..c59afa1cac 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -288,6 +288,12 @@ const MAX_NODES: usize = 500_000; /// nodes, so a shared group holding a large null-filled child array could still be rescanned /// many times; this bounds that work. Generous relative to MAX_NODES for real documents. const MAX_CHILD_SLOTS: usize = 8_000_000; +/// Bytes of layer names the model may retain across all occurrences. +const MAX_NAME_BYTES: usize = 64 << 20; +/// Page rectangles the model may retain across all spreads (real books have a few hundred). +const MAX_PAGES: usize = 1 << 18; +/// Bytes of font names the text runs may retain across the document. +const MAX_FONT_NAME_BYTES: usize = 16 << 20; /// Read a document: archive, `doc.dat` and the model. pub fn read(bytes: &[u8], limits: Limits) -> Result { @@ -304,6 +310,9 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, child_slots: usize, + name_bytes: usize, + pages: usize, + font_name_bytes: usize, active: HashSet, pub(crate) geometry_budget: crate::geometry::Budget, /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so @@ -320,6 +329,9 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { warnings: BTreeMap::new(), nodes: 0, child_slots: 0, + name_bytes: 0, + pages: 0, + font_name_bytes: 0, active: HashSet::new(), geometry_budget: crate::geometry::Budget::default(), pixels_left: limits.max_pixel_bytes, @@ -361,7 +373,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.warn("an unknown kind of page"); continue; } - let pages = pages(s, spread); + let pages = self.pages(spread)?; let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { x0: 0.0, @@ -394,10 +406,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { // Charge every raw child slot (null slots included) against a document-wide budget // before filtering. A shared group with a large null-filled Chld array could otherwise // be rescanned through each reference without the node limit noticing. - self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0)); - if self.child_slots > MAX_CHILD_SLOTS { - return Err(Error::Limit("too many child slots in the layer tree")); - } + self.charge_slots(s.array_len(parent, b"Chld").unwrap_or(0))?; for c in s.objs(parent, b"Chld") { if s.is(c, b"MRst") { // A mask layer inside a group or layer masks that container. @@ -419,6 +428,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Ok((out, mask)) } + /// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`]. + pub(crate) fn charge_slots(&mut self, n: usize) -> Result<(), Error> { + self.child_slots = self.child_slots.saturating_add(n); + if self.child_slots > MAX_CHILD_SLOTS { + return Err(Error::Limit("too many child slots in the layer tree")); + } + Ok(()) + } + fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { let s = self.s; if depth > MAX_TREE_DEPTH { @@ -437,7 +455,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); - let name = s.str(id, b"Desc").unwrap_or_default().to_string(); + // A shared layer's name is copied for every occurrence, so charge the bytes first. + let name = s.str(id, b"Desc").unwrap_or_default(); + self.name_bytes = self.name_bytes.saturating_add(name.len()); + if self.name_bytes > MAX_NAME_BYTES { + return Err(Error::Limit("layer names too large")); + } + let name = name.to_string(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); @@ -460,6 +484,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let (mask, attached_mask) = self.attached_masks(id, world)?; let pixel_mask = attached_mask.or(child_mask); self.active.remove(&id); + self.charge_slots(s.array_len(id, b"FiEf").unwrap_or(0))?; if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) { self.warn("layer effects (shadows, glows, outlines, bevels…)"); } @@ -536,6 +561,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option, Option), Error> { let s = self.s; + // Every occurrence of a shared layer scans its whole attachment array, null slots included. + self.charge_slots(s.array_len(id, b"AdCh").unwrap_or(0))?; let attached = s.objs(id, b"AdCh"); let mut mask: Option = None; let mut pixels: Option = None; @@ -584,21 +611,51 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } } -/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` -/// equal pages (Publisher 1 facing spreads). -fn pages(s: &Stream, spread: ObjId) -> Vec { - if let Some(md) = s.obj(spread, b"SpMd") { - let v: Vec = s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); - if !v.is_empty() { - return v; +impl Reader<'_, '_, '_> { + /// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` + /// equal pages (Publisher 1 facing spreads). A spread listed several times, or sharing one + /// large page array, is expanded into a new vector each time, so the slots scanned and the + /// rectangles retained are charged across the document. + fn pages(&mut self, spread: ObjId) -> Result, Error> { + let s = self.s; + if let Some(md) = s.obj(spread, b"SpMd") { + self.charge_slots(s.array_len(md, b"PagR").unwrap_or(0))?; + let remaining = MAX_PAGES.saturating_sub(self.pages); + let v: Vec = s + .objs(md, b"PagR") + .into_iter() + .filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)) + .filter(|r| r.x1 > r.x0 && r.y1 > r.y0) + .take(remaining.saturating_add(1)) + .collect(); + if !v.is_empty() { + self.charge_pages(v.len())?; + return Ok(v); + } } + Ok(match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { + (Some(b), Some(n @ 2..=16)) => { + self.charge_pages(n as usize)?; + let w = (b.x1 - b.x0) / n as f64; + (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() + } + _ => Vec::new(), + }) } - match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { - (Some(b), Some(n @ 2..=16)) => { - let w = (b.x1 - b.x0) / n as f64; - (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() + + /// Counts retained font-name bytes against [`MAX_FONT_NAME_BYTES`]; false once it is spent. + pub(crate) fn charge_font_name(&mut self, n: usize) -> bool { + self.font_name_bytes = self.font_name_bytes.saturating_add(n); + self.font_name_bytes <= MAX_FONT_NAME_BYTES + } + + /// Counts retained page rectangles against [`MAX_PAGES`]. + fn charge_pages(&mut self, n: usize) -> Result<(), Error> { + self.pages = self.pages.saturating_add(n); + if self.pages > MAX_PAGES { + return Err(Error::Limit("too many pages")); } - _ => Vec::new(), + Ok(()) } } diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 343c276b60..1847fad90f 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -232,9 +232,15 @@ 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).collect(), + Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(), _ => Vec::new(), }; let mut stops = Vec::new(); diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index bb1ecc112f..226c66a459 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -181,24 +181,32 @@ fn plane( let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE)); let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); - let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), - _ => Vec::new(), + // Borrow the file-controlled arrays: collecting the whole state array first costs its full + // length on every occurrence of a shared layer, however small the declared grid is. + let states: &[Value] = match s.field(bitmap, &tag(b"Sta", c)) { + Some(Value::Array(a)) => a, + _ => &[], }; - let tiles = s.objs(bitmap, &tag(b"Idx", c)); + let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) { + Some(Value::Array(a)) => a.as_slice(), + _ => &[], + } + .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; let mut out = vec![0u8; row * h as usize]; let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); let (py0, py1) = (y0 as usize, (y0 + h) as usize); - let mut next = 0usize; for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { + let state = &if let Value::UInt(u) = state { u8::try_from(*u).unwrap_or(255) } else { 255 }; let (ty, tx) = (t / tw.max(1), t % tw.max(1)); let fill = match state { 0 | 1 => None, 2 => Some(0xFF), 4 => { - let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) }; - next += 1; + let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) }; 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/stream.rs b/crates/affinity/src/stream.rs index 2ffe12b7e5..7c7d47017b 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -332,7 +332,8 @@ impl Parser<'_> { 0x2c => { let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; let size = usize::from(self.c.u16()?); - if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { + // A zero width passes the length check for any count, then costs a Value per record. + 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)?; diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 2c68a064a3..ab59c440b4 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -1,4 +1,4 @@ -//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents. +//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents. mod preview { use crate::container::header; @@ -258,6 +258,20 @@ mod archive { assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } + #[test] + fn a_failed_read_still_spends_the_extraction_allowance() { + let d = doc(); + let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); + let mut broken = file.clone(); + broken[90] ^= 0x55; + // Room for exactly one read: the failed attempt must use it up, so a caller that + // recovers from the checksum error cannot make the same entry decode again. + let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len(), ..Limits::default() }).unwrap(); + assert!(a.read("doc.dat").is_err()); + assert_eq!(a.extracted(), d.len()); + assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); + } + #[test] fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() { let big = vec![7u8; 1 << 20]; @@ -302,6 +316,17 @@ mod archive { } } + #[test] + fn fixed_records_of_zero_width_are_rejected_before_any_value_is_made() { + // Four million records of no bytes each pass a length check that multiplies by the width. + let mut f = vec![0x80 | 0x2c]; + f.extend(tag(b"Big_").0.to_le_bytes()); + f.extend(4_000_000u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + let d = synth::stream(&[(tag(b"Big_"), F::Raw(f))]); + assert_eq!(stream::parse(&d).unwrap_err(), Error::Malformed("array longer than its data")); + } + #[test] fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() { // Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index c34e4e436a..bfad07217f 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -26,11 +26,17 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { None => { // Inline objects (page numbers, anchors) each take one index before the segment text. let mut v = Vec::new(); + // One past the allowance is enough to trip the check below; shared segments + // repeated by reference must not expand beyond it first. + let room = MAX_CHARS.saturating_sub(total).saturating_add(1); for seg in s.objs(glyphs, b"Mixd") { + if v.len() >= room { + break; + } let inline = s.objs(seg, b"Glys").len(); - v.extend(std::iter::repeat_n('\u{FFFC}', inline)); + v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(room - v.len()))); placeholders |= inline > 0; - v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars()); + v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(room.saturating_sub(v.len()))); } v } @@ -100,6 +106,24 @@ fn int(v: &Value) -> Option { } } +/// Longest font name kept; real PostScript and family names are far shorter. +const MAX_FONT_NAME: usize = 256; + +/// A font name for one run. Runs repeat the same shared string, so its copies are charged +/// against a document-wide byte budget; an overlong name or an exhausted budget leaves it empty. +fn font_name(r: &mut Reader, font: Option, tag: &[u8; 4]) -> String { + let name = font.and_then(|f| r.s.str(f, tag)).unwrap_or_default(); + if name.len() > MAX_FONT_NAME { + r.warn("font names longer than 256 bytes (left empty)"); + return String::new(); + } + if !r.charge_font_name(name.len()) { + r.warn("font names beyond the document metadata budget (left empty)"); + return String::new(); + } + name.to_string() +} + fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun { let s = r.s; let doubles = s.field(attrs, b"Doub"); @@ -119,11 +143,12 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR r.warn("horizontally scaled text"); } let font = s.obj(attrs, b"DFnt"); - let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string(); + let postscript = font_name(r, font, b"Post"); + let family = font_name(r, font, b"Famy"); TextRun { text, - postscript: get(b"Post"), - family: get(b"Famy"), + postscript, + family, weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400), italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), size, diff --git a/crates/affinity/tests/compound_limits.rs b/crates/affinity/tests/it/compound_limits.rs similarity index 97% rename from crates/affinity/tests/compound_limits.rs rename to crates/affinity/tests/it/compound_limits.rs index 76f2e6e4a1..6801f7f6d6 100644 --- a/crates/affinity/tests/compound_limits.rs +++ b/crates/affinity/tests/it/compound_limits.rs @@ -48,8 +48,10 @@ fn isolated(name: &str, check: impl FnOnce()) { check(); return; } + // The harness names a test by its module path below the crate root. + let test = module_path!().split_once("::").map_or(name.to_string(), |(_, m)| format!("{m}::{name}")); let mut child = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", name, "--nocapture"]) + .args(["--exact", &test, "--nocapture"]) .env("PHOTOCRAFT_COMPOUND_CASE", name) .spawn() .unwrap(); diff --git a/crates/affinity/tests/it/main.rs b/crates/affinity/tests/it/main.rs new file mode 100644 index 0000000000..5d0a5740b8 --- /dev/null +++ b/crates/affinity/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, linked into one test binary. + +mod compound_limits; +mod real_files; diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/it/real_files.rs similarity index 100% rename from crates/affinity/tests/real_files.rs rename to crates/affinity/tests/it/real_files.rs diff --git a/crates/algo/Cargo.toml b/crates/algo/Cargo.toml index c4f34b4b1d..af50c1d20e 100644 --- a/crates/algo/Cargo.toml +++ b/crates/algo/Cargo.toml @@ -21,4 +21,5 @@ rustfft = "6.4.1" rayon = { workspace = true } [dev-dependencies] +photocraft-testkit = { workspace = true } serde_json = { workspace = true } diff --git a/crates/algo/examples/bench_filters2.rs b/crates/algo/examples/bench_filters2.rs index 9849c4f843..f6b34aca28 100644 --- a/crates/algo/examples/bench_filters2.rs +++ b/crates/algo/examples/bench_filters2.rs @@ -1,13 +1,33 @@ //! Timing for the heavier second-batch filters on a ~24 MP (6000×4000) RGBA8 layer. -//! `cargo run --release -p photocraft-algo --example bench_filters2 [filter-name-substring]` +//! +//! `cargo run --release -p photocraft-algo --example bench_filters2 [name-substring] [--reps N] [--quick] [--json ]` +//! +//! `--json` writes the report `cargo xtask perf` reads: `{"rows": [{"name", "samples_ms"}]}`. +//! `--quick` runs the list on a 1500×1000 layer instead, for `cargo xtask perf --quick`. use photocraft_algo::*; use photocraft_color::PixelFormat; use photocraft_geom::Rect; use photocraft_raster::Surface; fn main() { - let only = std::env::args().nth(1); - let (w, h) = (6000, 4000); + let a: Vec = std::env::args().skip(1).collect(); + let flag = |k: &str| a.iter().position(|v| v == k); + let value = |k: &str| flag(k).and_then(|i| a.get(i + 1)).cloned(); + let mut only: Option = None; + let mut i = 0; + while i < a.len() { + match a[i].as_str() { + "--json" | "--reps" => i += 2, + "--quick" => i += 1, + _ => { + only.get_or_insert_with(|| a[i].clone()); + i += 1; + } + } + } + let reps: usize = value("--reps").and_then(|v| v.parse().ok()).unwrap_or(1).max(1); + let quick = flag("--quick").is_some(); + let (w, h) = if quick { (1500, 1000) } else { (6000, 4000) }; let r = Rect::new(0, 0, w, h); let mut s = Surface::new(PixelFormat::RGBA8); // Busy deterministic content (gradients plus texture) so edge-aware filters do real work. @@ -55,19 +75,49 @@ fn main() { }, FilterParams::Relight { angle: 45.0, elevation: 40.0, intensity: 40.0, ambient: 55.0, warmth: 0.0, softness: 25.0 }, ]; + let mut rows: Vec<(String, Vec)> = Vec::new(); for p in cases { if only.as_deref().is_some_and(|o| !p.label().to_lowercase().contains(&o.to_lowercase())) { continue; } let area = output_area(&p, s.content_bounds(), r, None).intersect(&r); - let t = std::time::Instant::now(); - let out = apply(&s, &p, area, r, None); // Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle. let name = match p { FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()), _ => p.label().to_string(), }; - println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed()); - drop(out); + let mut samples = Vec::with_capacity(reps); + for _ in 0..reps { + let t = std::time::Instant::now(); + let out = apply(&s, &p, area, r, None); + let ms = t.elapsed().as_secs_f64() * 1e3; + samples.push(ms); + drop(out); + } + let mut sorted = samples.clone(); + sorted.sort_by(f64::total_cmp); + let median = match sorted.len() % 2 { + 1 => sorted[sorted.len() / 2], + _ => (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0, + }; + println!("{name:<18} {w}x{h} RGBA8: {median:.1} ms{reps_note}", reps_note = if reps > 1 { format!(" (median of {reps})") } else { String::new() }); + rows.push((name, samples)); + } + if let Some(path) = value("--json") { + // Two fields per row and ASCII filter labels: hand-written rather than a serde dependency + // this crate does not otherwise need. + let body: Vec = rows + .iter() + .map(|(name, samples)| { + let s: Vec = samples.iter().map(|v| format!("{v:.4}")).collect(); + format!("{{\"name\":\"{name}\",\"samples_ms\":[{}]}}", s.join(",")) + }) + .collect(); + let text = format!("{{\"rows\":[{}]}}", body.join(",")); + if let Err(e) = std::fs::write(&path, text) { + eprintln!("write {path}: {e}"); + std::process::exit(1); + } + println!("report: {path}"); } } diff --git a/crates/algo/examples/bench_liquify.rs b/crates/algo/examples/bench_liquify.rs index 2eea3b951b..78165171f2 100644 --- a/crates/algo/examples/bench_liquify.rs +++ b/crates/algo/examples/bench_liquify.rs @@ -127,10 +127,23 @@ fn main() { photocraft_algo::puppet::ITERATIONS, t.elapsed().as_secs_f64() * 1e3 ); - let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect(); - let t = Instant::now(); - let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v)); - println!("puppet interactive re-solve (4 warm rounds): {:.2} ms", t.elapsed().as_secs_f64() * 1e3); + // Compare the old path-dependent preview with the command-equivalent drag solve, using + // changing destinations and the same cached factorization (no remeshing per event). + for (label, rounds, warm) in [("6 warm rounds", 6, true), ("canonical cold solve", photocraft_algo::puppet::ITERATIONS, false)] { + let mut previous = v.clone(); + let mut times = Vec::new(); + for step in 1..=60 { + let mut dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect(); + dst[1][0] -= f64::from(step) * 10.0; + dst[1][1] += (f64::from(step) * 0.1).sin() * 400.0; + let t = Instant::now(); + let (next, _) = solver.solve(&dst, &[None; 3], pw.mode, rounds, warm.then_some(previous.as_slice())); + times.push(t.elapsed().as_secs_f64() * 1e3); + previous = std::hint::black_box(next); + } + times.sort_by(f64::total_cmp); + println!("puppet drag ({label}, 60 moves): median {:.2} ms, p95 {:.2} ms, max {:.2} ms", times[30], times[56], times[59]); + } let t = Instant::now(); let _ = render(&s, b, &solver.mesh, &v, &order, Interp::Bicubic); println!("puppet render 24 MP: {:.0} ms", t.elapsed().as_secs_f64() * 1e3); diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs index 9d58b81a54..2e18f9457e 100644 --- a/crates/algo/examples/bench_multiband.rs +++ b/crates/algo/examples/bench_multiband.rs @@ -1,42 +1,161 @@ -//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels -//! (3:2), each overlapping the next by half its width. -//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP). -//! Peak memory is the growth of the process's high-water mark during the blend (Linux only). +//! Time and peak memory of Photomerge's multi-band blend on overlapping RGB sources. +//! Backwards compatible: `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]`. +//! `cargo xtask perf --bench bench_multiband --quick` also collects repeatable JSON timings. +//! The peak is the entire process's resident-set high-water mark, not an estimate of allocations +//! by the blend alone (`photocraft_testkit::perf`: the kernel's mark on Linux, sampled elsewhere). +use std::path::PathBuf; use std::time::Instant; use photocraft_algo::panorama::{self, RoiImage}; +use photocraft_testkit::perf::{self, RssSampler, Summary}; +use serde_json::json; -/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it. -fn peak_mib() -> Option { - let s = std::fs::read_to_string("/proc/self/status").ok()?; - let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; - Some(kb / 1024.0) +const ROW: &str = "Photomerge multiband blend"; + +#[derive(Debug, PartialEq)] +struct Options { + n: usize, + mp: f64, + reps: usize, + json: Option, } -fn main() { - let mut args = std::env::args().skip(1); - let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1); - let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0); - let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2); +/// Parses legacy `[n] [mp]` arguments and the perf runner's `--reps`/`--json` flags. +/// Rejects missing, out-of-range, or non-finite values before allocating images. +fn options(args: impl IntoIterator) -> Result { + let mut out = Options { n: 4, mp: 24.0, reps: 1, json: None }; + let mut positional = Vec::new(); + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--json" => { + let path = it.next().filter(|p| !p.starts_with('-')).ok_or("--json needs a file path")?; + out.json = Some(PathBuf::from(path)); + } + "--reps" => { + out.reps = it.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "--reps needs an integer")?; + } + _ if !arg.starts_with('-') => positional.push(arg), + _ => return Err(format!("unknown option: {arg}")), + } + } + if positional.len() > 2 { + return Err("expected at most [n] [mp]".into()); + } + if let Some(n) = positional.first() { + out.n = n.parse().map_err(|_| "n must be an integer")?; + } + if let Some(mp) = positional.get(1) { + out.mp = mp.parse().map_err(|_| "mp must be a number")?; + } + if !(1..=16).contains(&out.n) || !(1..=25).contains(&out.reps) || !out.mp.is_finite() || !(0.01..=36.0).contains(&out.mp) { + return Err("expected 1..=16 images, 0.01..=36 MP per image and 1..=25 repetitions".into()); + } + Ok(out) +} + +/// Builds deterministic overlapping RGB inputs and times each multi-band blend. +/// Writes individual samples and whole-process peak RSS to JSON when requested. +fn run() -> Result<(), String> { + let o = options(std::env::args().skip(1))?; + let sh = ((o.mp * 1e6 / 1.5).sqrt().round() as usize).max(2); let sw = sh * 3 / 2; let step = sw / 2; - let (cw, ch) = (step * (n - 1) + sw, sh); - let rois: Vec = (0..n) + let (cw, ch) = (step * (o.n - 1) + sw, sh); + let rois: Vec = (0..o.n) .map(|k| { let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect(); RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] } }) .collect(); - // Each canvas column belongs to the image whose centre is nearest, as a seam would split it. - let wts: Vec> = (0..n) - .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect()) + let wts: Vec> = (0..o.n) + .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(o.n - 1) == k { 1.0 } else { 0.0 }).collect()) .collect(); let levels = panorama::blend_levels(cw, ch).min(7); - let before = peak_mib(); - let t0 = Instant::now(); - let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels); - let ms = t0.elapsed().as_secs_f64() * 1000.0; - let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b)); - println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend"); - std::hint::black_box((px, cov)); + let before = perf::process_peak_rss_bytes().or_else(perf::current_rss_bytes); + let sampler = RssSampler::start(std::time::Duration::from_millis(2)); + let mut samples_ms = Vec::with_capacity(o.reps); + for _ in 0..o.reps { + let t0 = Instant::now(); + let output = panorama::multiband(cw, ch, &rois, &wts, levels); + samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0); + std::hint::black_box(&output); + } + let peak = perf::process_peak_rss_bytes().or(sampler.peak()); + let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0)); + // The same nearest-rank p50 that `cargo xtask perf` reports for these samples. + let p50 = Summary::of(&samples_ms).map_or(f64::NAN, |s| s.p50); + println!( + "{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms p50 ({} runs), peak +{}", + o.n, + sw, + sh, + o.mp, + cw, + ch, + levels, + p50, + o.reps, + delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB")) + ); + if let Some(path) = o.json { + let context = json!({"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels}); + let report = perf::report("bench_multiband", context, vec![perf::row(ROW, &samples_ms, peak, None)], Some(&sampler)); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + 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(()) +} + +/// Runs the benchmark, reporting argument or output errors with a failing exit status. +fn main() { + if let Err(e) = run() { + eprintln!("bench_multiband: {e}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Converts borrowed argument slices for the owned-string command-line parser. + fn parse(args: &[&str]) -> Result { + options(args.iter().map(|s| s.to_string())) + } + + /// Confirms backwards compatibility with the positional and perf-runner forms. + #[test] + fn parses_old_positional_args_and_perf_runner_args() { + assert_eq!(parse(&[]).unwrap().n, 4); + assert_eq!( + parse(&["2", "0.25", "--reps", "3", "--json", "out.json"]).unwrap(), + Options { n: 2, mp: 0.25, reps: 3, json: Some(PathBuf::from("out.json")) } + ); + // The 24-36 MP range AGENTS.md benchmarks at, as before the flags were added. + assert_eq!(parse(&["4", "36"]).unwrap().mp, 36.0); + } + + /// Checks invalid counts, non-finite sizes, repetitions, and missing flag values. + #[test] + fn rejects_bad_benchmark_args() { + for args in [ + vec!["0"], + vec!["17"], + vec!["2", "0"], + vec!["2", "NaN"], + vec!["2", "inf"], + vec!["2", "37"], + vec!["--reps", "0"], + vec!["--reps", "26"], + vec!["--json"], + vec!["--json", "--reps", "5"], + vec!["--reps"], + vec!["--unknown"], + ] { + assert!(parse(&args).is_err(), "{args:?}"); + } + } } diff --git a/crates/algo/examples/bench_pixelate.rs b/crates/algo/examples/bench_pixelate.rs index a37e8f0775..5c81d5d325 100644 --- a/crates/algo/examples/bench_pixelate.rs +++ b/crates/algo/examples/bench_pixelate.rs @@ -1,5 +1,6 @@ //! Full filter timing, including tile reads, filtering, writeback and pruning. //! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- facet 6000 4000 u8 3 output.bin +//! Also: crystallize, crystallize-large, pointillize, pointillize-large. //! Optional final argument after output.bin: rgb (default), gray, cmyk or lab. //! Run the same example at the base commit and the PR commit, then compare output.bin with cmp. use photocraft_algo::{FilterParams, apply_in}; @@ -29,11 +30,15 @@ fn main() -> Result<(), Box> { _ => return Err("depth must be u8, u16 or f32".into()), }; let params = match case { + "crystallize" => FilterParams::Crystallize { cell_size: 10.0, seed: 37 }, + "crystallize-large" => FilterParams::Crystallize { cell_size: 100.0, seed: 37 }, + "pointillize" => FilterParams::Pointillize { cell_size: 5.0, seed: 37, background: [1.0; 4] }, + "pointillize-large" => FilterParams::Pointillize { cell_size: 100.0, seed: 37, background: [1.0; 4] }, "facet" => FilterParams::Facet, "halftone" => FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] }, "halftone-small" => FilterParams::ColorHalftone { max_radius: 2.0, angles: [108.0, 162.0, 90.0, 45.0] }, "halftone-large" => FilterParams::ColorHalftone { max_radius: 64.0, angles: [108.0, 162.0, 90.0, 45.0] }, - _ => return Err("case must be facet, halftone, halftone-small or halftone-large".into()), + _ => return Err("unknown pixelate benchmark case".into()), }; let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") { "rgb" => ColorMode::Rgb, diff --git a/crates/algo/examples/bench_seam.rs b/crates/algo/examples/bench_seam.rs new file mode 100644 index 0000000000..50225c4b50 --- /dev/null +++ b/crates/algo/examples/bench_seam.rs @@ -0,0 +1,32 @@ +//! Timing and output hash for Content-Aware Scale's seam carving core (P28). +//! +//! `cargo run --release -p photocraft-algo --example bench_seam [width height new_width channels]` +//! +//! Prints the carve time and an FNV-1a hash of the output's raw f32 bits, so two builds can be +//! compared with `cmp`-level strictness without writing tens of megabytes of pixels to disk. +use std::time::Instant; + +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + let num = |i: usize, d: usize| a.get(i).and_then(|s| s.parse().ok()).unwrap_or(d); + let (w, h, new_w, ch) = (num(0, 6000), num(1, 4000), num(2, 5400), num(3, 4)); + if !(1..=4).contains(&ch) { + eprintln!("channels must be 1..=4"); + std::process::exit(2); + } + // Deterministic photo-ish content: gradients with fine detail, so the energy map carries + // structure instead of one flat value per row. + let img: Vec = (0..w * h) + .flat_map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let d = ((x * 0.13).sin() * (y * 0.07).cos()).abs() * 0.3; + [(x / w as f32) * 0.6 + d, (y / h as f32) * 0.9 - d, d, 1.0].into_iter().take(ch) + }) + .collect(); + let t = Instant::now(); + let out = photocraft_algo::seam::carve_width(w, h, ch, &img, None, new_w); + let ms = t.elapsed().as_secs_f64() * 1e3; + let hash = + out.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, v| v.to_bits().to_le_bytes().iter().fold(h, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3))); + println!("{w}x{h}x{ch} -> {new_w}: {ms:.1} ms, {} samples, fnv {hash:016x}", out.len()); +} diff --git a/crates/algo/examples/bench_seam_cas.rs b/crates/algo/examples/bench_seam_cas.rs new file mode 100644 index 0000000000..b8acbfe3f2 --- /dev/null +++ b/crates/algo/examples/bench_seam_cas.rs @@ -0,0 +1,78 @@ +//! Timing of Content-Aware Scale's seam carving alone (no document plumbing), on a synthetic +//! photo-like RGBA image: perf scenario P28's 24 MP width change (#211), the same image's height +//! (the transposed pass) and two images short enough for the exact full-resolution seam search. +//! Prints an FNV-style hash of each output's bits (over 32-bit words), so a speed-up can be checked +//! to choose exactly the same seams. +//! `cargo run --release -p photocraft-algo --example bench_seam_cas [reps] [case filter]` +use std::time::Instant; + +use photocraft_algo::seam::carve; + +/// Smooth gradients, a few hard-edged shapes and fine noise: enough structure that seams are +/// steered by content, as in a photo. +fn photo(w: usize, h: usize) -> Vec { + let mut seed = 0x9e37_79b9u32; + let mut noise = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + (seed % 1000) as f32 / 1000.0 - 0.5 + }; + let mut img = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let (u, v) = (x as f32 / w as f32, y as f32 / h as f32); + let sky = 0.35 + 0.4 * v; + let disc = ((u - 0.3).powi(2) + (v - 0.55).powi(2)) < 0.02; + let bar = (0.62..0.66).contains(&u) && v > 0.3; + let hill = v > 0.7 + 0.08 * (u * 11.0).sin(); + let (r, g, b) = if disc { + (0.85, 0.3, 0.2) + } else if bar { + (0.1, 0.1, 0.12) + } else if hill { + (0.2, 0.45 + 0.1 * (u * 40.0).sin(), 0.15) + } else { + (sky * 0.6, sky * 0.8, sky) + }; + let n = noise() * 0.02; + img.extend_from_slice(&[r + n, g + n, b + n, 1.0]); + } + } + img +} + +fn fnv(v: &[f32]) -> u64 { + v.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, f| (h ^ u64::from(f.to_bits())).wrapping_mul(0x0100_0000_01b3)) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let reps = args.get(1).and_then(|s| s.parse::().ok()).unwrap_or(1).clamp(1, 20); + let only = args.get(2).cloned(); + // (name, w, h, new w, new h) + let cases = [ + ("24 MP to 90 % width (P28, proxy rows)", 6000, 4000, 5400, 4000), + ("24 MP to 90 % height (transposed, proxy rows)", 6000, 4000, 6000, 3600), + ("5.4 MP to 90 % width (exact)", 3000, 1800, 2700, 1800), + ("1.5 MP to 70 % width (exact)", 1500, 1000, 1050, 1000), + ]; + for (name, w, h, nw, nh) in cases { + if only.as_ref().is_some_and(|o| !name.contains(o.as_str())) { + continue; + } + let img = photo(w, h); + let mut times = Vec::with_capacity(reps); + let mut hash = 0; + for _ in 0..reps { + let t = Instant::now(); + let out = carve(w, h, 4, &img, None, nw, nh); + times.push(t.elapsed().as_secs_f64() * 1000.0); + hash = fnv(&out); + } + times.sort_by(f64::total_cmp); + let p50 = times[times.len() / 2]; + let max = times[times.len() - 1]; + println!("{name:<48} p50 {p50:>9.1} ms max {max:>9.1} ms output {hash:016x}"); + } +} diff --git a/crates/algo/src/art_history.rs b/crates/algo/src/art_history.rs new file mode 100644 index 0000000000..7fcb286c22 --- /dev/null +++ b/crates/algo/src/art_history.rs @@ -0,0 +1,1053 @@ +//! Oriented history-state dabs: sample a snapshot patch, pose it along the stroke, and stamp. +//! +//! Tile processing reads each dab's rotated source straight from the history state, so a dab +//! that crosses a tile edge paints the same as an untiled pass. Scatter is seeded, so the same +//! stroke is replayable. + +#![allow(clippy::needless_range_loop)] + +/// Mixes the history-state index into dab scatter so two documents with different snapshots diverge. +pub const RNG_SEED: u32 = 0xA7B1_57E0; + +/// Largest number of dabs one stroke may emit. +pub const MAX_DABS: usize = 4096; + +/// Largest coverage rectangle, in pixels, a stroke may allocate. +pub const MAX_COVERAGE: u64 = 4096 * 4096; + +/// Why [`stamp`] refused to write. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StampError { + /// A zero-sized buffer, no dabs, or nothing to cover. + Empty, + /// Source and destination disagree on channel count. + Mismatched, + /// The read/write window would exceed [`MAX_COVERAGE`]. + TooLarge, +} + +/// Named dab layout. Multipliers are documented in the command params; keep them stable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Style { + TightShort, + TightMedium, + TightLong, + LooseMedium, + LooseLong, + Dab, + TightCurl, + LooseCurl, +} + +impl Style { + /// Parse a command-style identifier (`tightMedium`, …). + pub fn parse(name: &str) -> Option { + Some(match name { + "tightShort" => Self::TightShort, + "tightMedium" => Self::TightMedium, + "tightLong" => Self::TightLong, + "looseMedium" => Self::LooseMedium, + "looseLong" => Self::LooseLong, + "dab" => Self::Dab, + "tightCurl" => Self::TightCurl, + "looseCurl" => Self::LooseCurl, + _ => return None, + }) + } + + /// Stable id used in command params. + pub fn as_str(self) -> &'static str { + match self { + Self::TightShort => "tightShort", + Self::TightMedium => "tightMedium", + Self::TightLong => "tightLong", + Self::LooseMedium => "looseMedium", + Self::LooseLong => "looseLong", + Self::Dab => "dab", + Self::TightCurl => "tightCurl", + Self::LooseCurl => "looseCurl", + } + } + + fn spacing_mul(self) -> f32 { + match self { + Self::TightShort => 0.35, + Self::TightMedium => 0.55, + Self::TightLong => 0.85, + Self::LooseMedium => 0.70, + Self::LooseLong => 1.10, + Self::Dab => 1.60, + Self::TightCurl => 0.50, + Self::LooseCurl => 0.80, + } + } + + fn scatter_mul(self) -> f32 { + match self { + Self::TightShort => 0.00, + Self::TightMedium => 0.00, + Self::TightLong => 0.05, + Self::LooseMedium => 0.45, + Self::LooseLong => 0.70, + Self::Dab => 0.10, + Self::TightCurl => 0.05, + Self::LooseCurl => 0.40, + } + } + + fn curl_deg(self) -> f32 { + match self { + Self::TightCurl => 18.0, + Self::LooseCurl => 28.0, + _ => 0.0, + } + } + + fn size_mul(self) -> f32 { + match self { + Self::TightShort => 0.7, + Self::TightMedium => 0.9, + Self::TightLong => 1.1, + Self::LooseMedium => 1.0, + Self::LooseLong => 1.2, + Self::Dab => 1.0, + Self::TightCurl => 0.9, + Self::LooseCurl => 1.1, + } + } +} + +/// One posed dab in document pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Dab { + pub x: f32, + pub y: f32, + pub theta: f32, + pub radius: f32, + pub pressure: f32, +} + +/// Spacing along the stroke, in pixels. +pub fn spacing_px(size: f32, area: f32, style: Style) -> Option { + if !(size.is_finite() && area.is_finite()) || size <= 0.0 { + return None; + } + let s = size * style.spacing_mul() * (0.25 + 0.02 * area); + if s.is_finite() && s > 0.0 { Some(s) } else { None } +} + +/// Reach of a dab around its stroke point: half the dab plus its scatter, plus a margin. +pub fn halo_radius(size: f32, style: Style) -> Option { + if !size.is_finite() || size <= 0.0 { + return None; + } + let v = (size * style.size_mul() / 2.0 + style.scatter_mul() * size + 2.0).ceil(); + if !v.is_finite() || v > 1_000_000.0 { + return None; + } + Some(v as i32) +} + +/// Side of the rotated source square (`ceil(size · size_mul) + 2`). +pub fn sample_side(size: f32, style: Style) -> Option { + if !size.is_finite() || size <= 0.0 { + return None; + } + let v = (size * style.size_mul()).ceil() + 2.0; + if !v.is_finite() || v > 1_000_000.0 { + return None; + } + Some(v as i32) +} + +fn mix32(seed: u32, dab: u32, lane: u32) -> u32 { + let mut x = seed ^ dab.wrapping_mul(0x9E37_79B9) ^ lane.wrapping_mul(0x85EB_CA6B); + x = x.wrapping_add(0x9E37_79B9); + x ^= x >> 16; + x = x.wrapping_mul(0x7FEB_352D); + x ^= x >> 15; + x = x.wrapping_mul(0x846C_A68B); + x ^= x >> 16; + x +} + +fn signed01(seed: u32, dab: u32, lane: u32) -> f32 { + let u = mix32(seed, dab, lane) >> 8; + (u as f32) * (2.0 / 16_777_215.0) - 1.0 +} + +fn dab_radius(size: f32, style: Style) -> Option { + let r = size * style.size_mul() * 0.5; + if r.is_finite() && r > 0.0 { Some(r) } else { None } +} + +/// Walk `points` and emit posed dabs. `seed` is typically [`RNG_SEED`] XOR the history index. +pub fn place_dabs(points: &[[f32; 2]], size: f32, area: f32, style: Style, seed: u32) -> Result, StampError> { + if points.is_empty() { + return Err(StampError::Empty); + } + let spacing = spacing_px(size, area, style).ok_or(StampError::Empty)?; + let radius = dab_radius(size, style).ok_or(StampError::TooLarge)?; + let curl = style.curl_deg().to_radians(); + let scatter = style.scatter_mul(); + let mut out = Vec::new(); + let mut emit = |i: u32, x: f32, y: f32, tx: f32, ty: f32, pressure: f32| -> Result<(), StampError> { + if !(x.is_finite() && y.is_finite() && tx.is_finite() && ty.is_finite()) { + return Err(StampError::Empty); + } + let u = signed01(seed, i, 1); + let v = signed01(seed, i, 2); + let w = signed01(seed, i, 3); + let dx = scatter * size * u; + let dy = scatter * size * v; + let (cx, cy) = (x + dx, y + dy); + if !(cx.is_finite() && cy.is_finite()) { + return Err(StampError::TooLarge); + } + let theta = ty.atan2(tx) + (i as f32) * curl + scatter * w; + if !theta.is_finite() { + return Err(StampError::Empty); + } + if out.len() >= MAX_DABS { + return Err(StampError::TooLarge); + } + out.push(Dab { x: cx, y: cy, theta, radius, pressure: pressure.clamp(0.0, 1.0) }); + Ok(()) + }; + + let Some(first) = points.first() else { + return Err(StampError::Empty); + }; + if !first[0].is_finite() || !first[1].is_finite() { + return Err(StampError::Empty); + } + let (mut px, mut py) = (first[0], first[1]); + let (mut tx, mut ty) = (1.0f32, 0.0f32); + if let Some(second) = points.get(1) { + tx = second[0] - px; + ty = second[1] - py; + if tx == 0.0 && ty == 0.0 { + tx = 1.0; + } + } + emit(0, px, py, tx, ty, 1.0)?; + + let mut carry = 0.0f32; + let mut i = 1u32; + for w in points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) { + return Err(StampError::Empty); + } + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = dx.hypot(dy); + if !len.is_finite() { + return Err(StampError::Empty); + } + if len < 1.0e-6 { + continue; + } + tx = dx; + ty = dy; + let mut consumed = 0.0f32; + while carry + (len - consumed) >= spacing { + let need = spacing - carry; + if !need.is_finite() || need < 0.0 { + return Err(StampError::Empty); + } + consumed += need; + if consumed > len { + break; + } + let t = consumed / len; + px = a[0] + dx * t; + py = a[1] + dy * t; + emit(i, px, py, tx, ty, 1.0)?; + i = i.saturating_add(1); + carry = 0.0; + } + carry += len - consumed; + if !carry.is_finite() { + return Err(StampError::Empty); + } + } + if out.is_empty() { Err(StampError::Empty) } else { Ok(out) } +} + +/// Bounding rectangle of a dab, in document pixels (1 px of anti-alias slack). +pub fn dab_bounds(dab: &Dab) -> Option<(i32, i32, i32, i32)> { + let r = dab.radius + 1.0; + if !r.is_finite() { + return None; + } + let x0 = (dab.x - r).floor(); + let y0 = (dab.y - r).floor(); + let x1 = (dab.x + r).ceil(); + let y1 = (dab.y + r).ceil(); + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return None; + } + let clamp = |v: f32| v.clamp(i32::MIN as f32, i32::MAX as f32) as i32; + Some((clamp(x0), clamp(y0), clamp(x1), clamp(y1))) +} + +fn coverage_at(dab: &Dab, x: i32, y: i32, hardness: f32) -> f32 { + let dx = x as f32 + 0.5 - dab.x; + let dy = y as f32 + 0.5 - dab.y; + let d = dx.hypot(dy); + if !d.is_finite() || d >= dab.radius + 0.5 { + return 0.0; + } + let edge = (dab.radius + 0.5 - d).clamp(0.0, 1.0); + let h = hardness.clamp(0.0, 1.0); + let inner = dab.radius * h; + let falloff = if d <= inner || dab.radius - inner < 1.0e-3 { + 1.0 + } else { + let t = ((d - inner) / (dab.radius - inner)).clamp(0.0, 1.0); + 1.0 - t * t * (3.0 - 2.0 * t) + }; + edge * falloff +} + +fn pixel_index(x: usize, y: usize, w: usize, h: usize, ch: usize) -> Option { + if x >= w || y >= h || ch == 0 { + return None; + } + y.checked_mul(w)?.checked_add(x)?.checked_mul(ch) +} + +fn origin_index(origin: (i32, i32), x: i32, y: i32, w: usize, h: usize, ch: usize) -> Option { + let dx = x.checked_sub(origin.0)?; + let dy = y.checked_sub(origin.1)?; + pixel_index(usize::try_from(dx).ok()?, usize::try_from(dy).ok()?, w, h, ch) +} + +fn floor_i32(v: f32) -> i32 { + v.clamp(-2_147_483_648.0, 2_147_483_647.0).floor() as i32 +} + +struct SrcView<'a> { + data: &'a [f32], + w: usize, + h: usize, + origin: (i32, i32), + ch: usize, +} + +fn sample_bilinear(src: SrcView<'_>, sx: f32, sy: f32, out: &mut [f32]) -> bool { + let ch = src.ch; + if ch == 0 || out.len() < ch { + return false; + } + let dst = &mut out[..ch]; + dst.fill(0.0); + if !(sx.is_finite() && sy.is_finite()) { + return false; + } + // `sx, sy` are continuous coordinates; pixel `i` has its centre at `i + 0.5`. + let lx = sx - src.origin.0 as f32 - 0.5; + let ly = sy - src.origin.1 as f32 - 0.5; + if !(lx.is_finite() && ly.is_finite()) { + return false; + } + let x0 = floor_i32(lx); + let y0 = floor_i32(ly); + let fx = (lx - x0 as f32).clamp(0.0, 1.0); + let fy = (ly - y0 as f32).clamp(0.0, 1.0); + let mut any = false; + for (iy, wy) in [(0i32, 1.0 - fy), (1i32, fy)] { + for (ix, wx) in [(0i32, 1.0 - fx), (1i32, fx)] { + let wt = wx * wy; + if wt <= 0.0 { + continue; + } + let px = x0.saturating_add(ix); + let py = y0.saturating_add(iy); + if px < 0 || py < 0 { + continue; + } + let (Ok(ux), Ok(uy)) = (usize::try_from(px), usize::try_from(py)) else { continue }; + let Some(base) = pixel_index(ux, uy, src.w, src.h, ch) else { continue }; + let Some(pxs) = src.data.get(base..base.saturating_add(ch)) else { continue }; + for (d, s) in dst.iter_mut().zip(pxs.iter()) { + *d += *s * wt; + } + any = true; + } + } + any +} + +fn rotated_source(dab: &Dab, x: i32, y: i32) -> (f32, f32) { + let dx = x as f32 + 0.5 - dab.x; + let dy = y as f32 + 0.5 - dab.y; + let (c, s) = (dab.theta.cos(), dab.theta.sin()); + (dab.x + c * dx + s * dy, dab.y - s * dx + c * dy) +} + +fn over_native(dst: &mut [f32], src: &[f32], k: f32, alpha: Option, lock: bool) { + if k <= 0.0 { + return; + } + match alpha { + Some(a) => { + let Some((&da, &sa0)) = dst.get(a).zip(src.get(a)) else { return }; + let sa = sa0 * k; + let oa = sa + da * (1.0 - sa); + if oa > 0.0 { + for c in 0..a { + let Some(d) = dst.get_mut(c) else { continue }; + let Some(&s) = src.get(c) else { continue }; + *d = (s * sa + *d * da * (1.0 - sa)) / oa; + } + } + if !lock && let Some(d) = dst.get_mut(a) { + *d = oa; + } + } + None => { + for (d, s) in dst.iter_mut().zip(src.iter()) { + *d += (*s - *d) * k; + } + } + } +} + +fn buf_len(w: usize, h: usize, ch: usize) -> Option { + w.checked_mul(h)?.checked_mul(ch) +} + +/// Parameters for [`stamp`]. +pub struct StampParams<'a> { + pub dest: &'a mut [f32], + pub dest_w: usize, + pub dest_h: usize, + pub dest_origin: (i32, i32), + pub src: &'a [f32], + pub src_w: usize, + pub src_h: usize, + pub src_origin: (i32, i32), + pub ch: usize, + pub dabs: &'a [Dab], + pub hardness: f32, + pub opacity: f32, + pub flow: f32, + pub tolerance: f32, + /// Destination tile edge; `0` processes the whole buffer as one tile. + pub tile: i32, + pub selection: Option<&'a [f32]>, + pub lock_transparent: bool, + pub alpha: Option, +} + +fn dest_pixels(p: &StampParams<'_>) -> Result { + let n = buf_len(p.dest_w, p.dest_h, p.ch).ok_or(StampError::TooLarge)?; + if p.dest.len() < n { + return Err(StampError::Empty); + } + let sn = buf_len(p.src_w, p.src_h, p.ch).ok_or(StampError::TooLarge)?; + if p.src.len() < sn { + return Err(StampError::Empty); + } + if p.ch == 0 || p.dest_w == 0 || p.dest_h == 0 { + return Err(StampError::Empty); + } + Ok(n as u64) +} + +fn tile_grid(w: usize, h: usize, origin: (i32, i32), tile: i32) -> Vec<(i32, i32, i32, i32)> { + let (x0, y0) = origin; + let x1 = x0.saturating_add(w.min(i32::MAX as usize) as i32); + let y1 = y0.saturating_add(h.min(i32::MAX as usize) as i32); + if tile <= 0 { + return vec![(x0, y0, x1, y1)]; + } + let mut tiles = Vec::new(); + let mut y = y0; + while y < y1 { + let ny = y.saturating_add(tile).min(y1); + let mut x = x0; + while x < x1 { + let nx = x.saturating_add(tile).min(x1); + tiles.push((x, y, nx, ny)); + x = nx; + } + y = ny; + } + tiles +} + +fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i32)) -> Result { + let (x0, y0, x1, y1) = rect; + if x1 <= x0 || y1 <= y0 { + return Ok(0); + } + let Some(tw) = (x1 as i64 - x0 as i64).try_into().ok().filter(|w: &usize| *w > 0) else { + return Err(StampError::TooLarge); + }; + let Some(th) = (y1 as i64 - y0 as i64).try_into().ok().filter(|h: &usize| *h > 0) else { + return Err(StampError::TooLarge); + }; + let area = (tw as u64).saturating_mul(th as u64); + if area > MAX_COVERAGE { + return Err(StampError::TooLarge); + } + let n = tw.checked_mul(th).and_then(|m| m.checked_mul(p.ch)).ok_or(StampError::TooLarge)?; + let mut local = vec![0.0f32; n]; + let mut cov = vec![0.0f32; tw.checked_mul(th).ok_or(StampError::TooLarge)?]; + // Copy dest tile into local. + for y in y0..y1 { + for x in x0..x1 { + let lx = (x - x0) as usize; + let ly = (y - y0) as usize; + let Some(di) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, p.ch) else { continue }; + let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue }; + if let (Some(dst), Some(src)) = (local.get_mut(li..li + p.ch), p.dest.get(di..di + p.ch)) { + dst.copy_from_slice(src); + } + if let Some(c) = cov.get_mut(ly.saturating_mul(tw).saturating_add(lx)) { + *c = coverage_at(dab, x, y, p.hardness); + } + } + } + + // The rotated read keeps the distance to the dab centre, so it stays inside the dab's bounds: + // sample the source directly, whichever tile the destination pixel is in. + let mut sampled = vec![0.0f32; p.ch]; + let mut written = 0u32; + let k_stroke = (p.opacity * p.flow * dab.pressure).clamp(0.0, 1.0); + for y in y0..y1 { + for x in x0..x1 { + let lx = (x - x0) as usize; + let ly = (y - y0) as usize; + let Some(&c) = cov.get(ly.saturating_mul(tw).saturating_add(lx)) else { continue }; + if c <= 0.0 { + continue; + } + let mut k = c * k_stroke; + if let Some(sel) = p.selection { + let Some(si) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, 1) else { continue }; + let Some(&sv) = sel.get(si) else { continue }; + k *= sv.clamp(0.0, 1.0); + } + if k <= 0.0 { + continue; + } + let (sx, sy) = rotated_source(dab, x, y); + if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch }, sx, sy, &mut sampled) { + continue; + } + let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue }; + let Some(dst) = local.get_mut(li..li + p.ch) else { continue }; + if p.lock_transparent && p.alpha.is_some_and(|a| dst.get(a).is_some_and(|&v| v <= 0.0)) { + continue; + } + over_native(dst, &sampled, k, p.alpha, p.lock_transparent); + written = written.saturating_add(1); + } + } + + // Write the dest tile back. + for y in y0..y1 { + for x in x0..x1 { + let lx = (x - x0) as usize; + let ly = (y - y0) as usize; + let Some(di) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, p.ch) else { continue }; + let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue }; + if let (Some(dst), Some(src)) = (p.dest.get_mut(di..di + p.ch), local.get(li..li + p.ch)) { + dst.copy_from_slice(src); + } + } + } + Ok(written) +} + +fn dab_intersects(dab: &Dab, tile: (i32, i32, i32, i32)) -> bool { + let Some((x0, y0, x1, y1)) = dab_bounds(dab) else { return false }; + x0 < tile.2 && x1 > tile.0 && y0 < tile.3 && y1 > tile.1 +} + +/// Mean difference between `src` and `dest` over the dab's covered pixels (working-space peak is +/// 1, so a tolerance of 50% is `0.5`). Per pixel it is the larger of the mean colour change and the +/// alpha change: an opaque pixel's unchanged alpha must not water a colour change down, and the +/// Eraser keeps the colour and lowers only the alpha. `None` if nothing is covered. +fn dab_mean_diff(p: &StampParams<'_>, dab: &Dab) -> Option { + let (x0, y0, x1, y1) = dab_bounds(dab)?; + let mut sampled = vec![0.0f32; p.ch]; + let mut sum = 0.0f32; + let mut count = 0u32; + for y in y0..y1 { + for x in x0..x1 { + if coverage_at(dab, x, y, p.hardness) <= 0.0 { + continue; + } + let Some(di) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, p.ch) else { continue }; + let Some(dst) = p.dest.get(di..di.saturating_add(p.ch)) else { continue }; + let (sx, sy) = rotated_source(dab, x, y); + if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch }, sx, sy, &mut sampled) { + continue; + } + let nch = p.ch.min(dst.len()).min(sampled.len()); + if nch == 0 { + continue; + } + let (mut colour, mut colours, mut alpha) = (0.0f32, 0u32, 0.0f32); + for i in 0..nch { + let Some((&s, &d)) = sampled.get(i).zip(dst.get(i)) else { continue }; + if p.alpha == Some(i) { + alpha = (s - d).abs(); + } else { + colour += (s - d).abs(); + colours += 1; + } + } + let colour = if colours == 0 { 0.0 } else { colour / colours as f32 }; + sum += colour.max(alpha); + count = count.saturating_add(1); + } + } + if count == 0 { + return None; + } + let mean = sum / count as f32; + mean.is_finite().then_some(mean) +} + +/// Stamp posed dabs into `dest`, tile by tile, reading `src` at each dab's rotated positions. +/// +/// Sequential dabs overlap: dab *n* sees dab *n − 1*. Returns how many destination pixels were +/// written (tolerance-skipped dabs count as zero). +pub fn stamp(p: &mut StampParams<'_>) -> Result { + dest_pixels(p)?; + if p.src_w == 0 || p.src_h == 0 { + return Err(StampError::Empty); + } + if p.dabs.is_empty() { + return Err(StampError::Empty); + } + let dest_area = (p.dest_w as u64).saturating_mul(p.dest_h as u64); + if dest_area > MAX_COVERAGE { + return Err(StampError::TooLarge); + } + let tiles = tile_grid(p.dest_w, p.dest_h, p.dest_origin, p.tile); + let mut written = 0u32; + let dabs: Vec = p.dabs.to_vec(); + for dab in &dabs { + let Some(bounds) = dab_bounds(dab) else { continue }; + if p.tolerance > 0.0 && dab_mean_diff(p, dab).is_some_and(|m| m < p.tolerance) { + continue; + } + let overlapping: Vec<(i32, i32, i32, i32)> = tiles.iter().copied().filter(|t| dab_intersects(dab, *t)).collect(); + if overlapping.is_empty() { + continue; + } + // Sequential tiles: each writes dest before the next dab. Parallel tiles of one dab do not + // share destination pixels, so they can run independently; we still apply them in order + // here so overlapping dabs stay deterministic. + for tile in overlapping { + let (bx0, by0, bx1, by1) = bounds; + let dest_rect = (tile.0.max(bx0), tile.1.max(by0), tile.2.min(bx1), tile.3.min(by1)); + written = written.saturating_add(stamp_dab_on_rect(p, dab, dest_rect)?); + } + } + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fill(w: usize, h: usize, ch: usize, f: impl Fn(i32, i32, usize) -> f32) -> Vec { + let mut v = vec![0.0; w * h * ch]; + for y in 0..h { + for x in 0..w { + for c in 0..ch { + v[(y * w + x) * ch + c] = f(x as i32, y as i32, c); + } + } + } + v + } + + fn horizontal(len: f32) -> Vec<[f32; 2]> { + vec![[10.0, 32.0], [10.0 + len, 32.0]] + } + + #[test] + fn tight_short_emits_more_dabs_than_tight_long() { + let pts = horizontal(100.0); + let short = place_dabs(&pts, 10.0, 50.0, Style::TightShort, RNG_SEED).unwrap(); + let long = place_dabs(&pts, 10.0, 50.0, Style::TightLong, RNG_SEED).unwrap(); + assert!(short.len() > long.len(), "short {} long {}", short.len(), long.len()); + let s = spacing_px(10.0, 50.0, Style::TightShort).unwrap(); + let l = spacing_px(10.0, 50.0, Style::TightLong).unwrap(); + assert!(s < l, "{s} {l}"); + } + + #[test] + fn tight_curl_angles_increase_and_loose_curl_scatters() { + let pts = horizontal(80.0); + let tight = place_dabs(&pts, 12.0, 50.0, Style::TightCurl, RNG_SEED).unwrap(); + let loose = place_dabs(&pts, 12.0, 50.0, Style::LooseCurl, RNG_SEED).unwrap(); + assert!(tight.len() >= 3 && loose.len() >= 3); + let dt: Vec = tight.windows(2).map(|w| w[1].theta - w[0].theta).collect(); + assert!(dt.iter().all(|d| *d > 0.05), "tight curl steps {dt:?}"); + let dl: Vec = loose.windows(2).map(|w| w[1].theta - w[0].theta).collect(); + let mean = |v: &[f32]| v.iter().sum::() / v.len() as f32; + assert!(mean(&dl) > mean(&dt), "loose curl {dl:?} vs tight {dt:?}"); + let scatter_t: f32 = tight.iter().map(|d| (d.y - 32.0).abs()).sum(); + let scatter_l: f32 = loose.iter().map(|d| (d.y - 32.0).abs()).sum(); + assert!(scatter_l > scatter_t, "loose scatter {scatter_l} tight {scatter_t}"); + } + + #[test] + fn seeded_scatter_is_replayable() { + let pts = horizontal(60.0); + let a = place_dabs(&pts, 16.0, 40.0, Style::LooseMedium, RNG_SEED ^ 3).unwrap(); + let b = place_dabs(&pts, 16.0, 40.0, Style::LooseMedium, RNG_SEED ^ 3).unwrap(); + assert_eq!(a, b); + let c = place_dabs(&pts, 16.0, 40.0, Style::LooseMedium, RNG_SEED ^ 4).unwrap(); + assert_ne!(a, c); + } + + fn fixture(ch: usize) -> (usize, usize, Vec, Vec, Dab) { + let (w, h) = (128usize, 96usize); + let src = fill(w, h, ch, |x, y, c| if c + 1 == ch && ch > 1 { 1.0 } else { ((x * 13 + y * 7 + c as i32 * 3).rem_euclid(251) as f32) / 255.0 }); + let dest0 = fill(w, h, ch, |_, _, c| if c + 1 == ch && ch > 1 { 1.0 } else { 0.1 }); + // Centre 1 px inside a 64-tile so the rotated read crosses the seam. + let dab = Dab { x: 63.0, y: 40.0, theta: 0.4, radius: 18.0, pressure: 1.0 }; + (w, h, src, dest0, dab) + } + + #[allow(clippy::too_many_arguments)] + fn stamp_run(w: usize, h: usize, ch: usize, src: &[f32], dest: &mut [f32], dab: &Dab, tile: i32) -> u32 { + stamp_dabs(w, h, ch, src, dest, std::slice::from_ref(dab), tile) + } + + #[allow(clippy::too_many_arguments)] + fn stamp_dabs(w: usize, h: usize, ch: usize, src: &[f32], dest: &mut [f32], dabs: &[Dab], tile: i32) -> u32 { + let mut p = StampParams { + dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs, + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile, + selection: None, + lock_transparent: false, + alpha: (ch > 1).then_some(ch - 1), + }; + stamp(&mut p).unwrap() + } + + fn max_abs(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())) + } + + fn max_u8(a: &[f32], b: &[f32]) -> i32 { + a.iter() + .zip(b) + .map(|(x, y)| { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as i32; + (q(*x) - q(*y)).abs() + }) + .max() + .unwrap_or(0) + } + + #[test] + fn tiled_stamp_matches_untiled_at_a_tile_seam() { + let (w, h, src, dest0, dab) = fixture(4); + let mut tiled = dest0.clone(); + let mut flat = dest0.clone(); + let n_t = stamp_run(w, h, 4, &src, &mut tiled, &dab, 64); + let n_f = stamp_run(w, h, 4, &src, &mut flat, &dab, 0); + assert!(n_t > 0 && n_f > 0, "wrote {n_t} tiled / {n_f} flat"); + assert_eq!(max_u8(&tiled, &flat), 0, "8-bit seam"); + assert!(max_abs(&tiled, &flat) <= 1.0 / 32_767.0, "16-bit seam {}", max_abs(&tiled, &flat)); + assert!(max_abs(&tiled, &flat) <= 1.0e-5, "32f seam {}", max_abs(&tiled, &flat)); + + let (w, h, src, dest0, dab) = fixture(1); + let mut tiled = dest0.clone(); + let mut flat = dest0; + stamp_run(w, h, 1, &src, &mut tiled, &dab, 64); + stamp_run(w, h, 1, &src, &mut flat, &dab, 0); + assert_eq!(max_u8(&tiled, &flat), 0, "gray 8-bit seam"); + } + + #[test] + fn leftward_and_curl_strokes_match_untiled_across_a_tile_edge() { + let (w, h, ch) = (128usize, 96usize, 4); + let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { ((x * 13 + y * 7 + c as i32 * 3).rem_euclid(251) as f32) / 255.0 }); + let dest0 = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.1 }); + for style in [Style::TightMedium, Style::TightCurl, Style::LooseCurl] { + let dabs = place_dabs(&[[110.0, 40.0], [20.0, 40.0]], 40.0, 50.0, style, RNG_SEED).unwrap(); + let mut tiled = dest0.clone(); + let mut flat = dest0.clone(); + stamp_dabs(w, h, ch, &src, &mut tiled, &dabs, 64); + stamp_dabs(w, h, ch, &src, &mut flat, &dabs, 0); + assert_eq!(max_abs(&tiled, &flat), 0.0, "{} seam at x = 64", style.as_str()); + } + } + + /// A dab at angle 0 copies the history pixel under each covered pixel, not a blend of it + /// and its lower-right neighbours. + #[test] + fn unrotated_dab_restores_the_source_pixel() { + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { ((x * 37 + y * 11 + c as i32 * 5).rem_euclid(97) as f32) / 96.0 }); + let mut dest = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.5 }); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + stamp_run(w, h, ch, &src, &mut dest, &dab, 0); + for (x, y) in [(24usize, 24usize), (20, 22), (27, 26)] { + let i = (y * w + x) * ch; + assert_eq!(&dest[i..i + ch], &src[i..i + ch], "pixel {x},{y}"); + } + } + + #[test] + fn halo_covers_rotated_read_reach() { + let size = 40.0; + for style in + [Style::TightShort, Style::TightMedium, Style::TightLong, Style::LooseMedium, Style::LooseLong, Style::Dab, Style::TightCurl, Style::LooseCurl] + { + let halo = halo_radius(size, style).unwrap(); + let side = sample_side(size, style).unwrap(); + let reach = (side + 1) / 2; + assert!(halo >= reach, "{:?}: halo {halo} < reach {reach} (side {side})", style.as_str()); + } + } + + #[test] + fn tolerance_skips_an_unchanged_layer() { + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { (x + y) as f32 / 100.0 }); + let mut dest = src.clone(); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + let mut p = StampParams { + dest: &mut dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src: &src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 1.0, + tile: 0, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + let n = stamp(&mut p).unwrap(); + assert_eq!(n, 0); + assert_eq!(dest, src); + } + + #[test] + fn tolerance_zero_writes_where_history_differs() { + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.9 }); + let mut dest = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.1 }); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + let mut p = StampParams { + dest: &mut dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src: &src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile: 0, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + let n = stamp(&mut p).unwrap(); + assert!(n > 0); + let i = (24 * w + 24) * ch; + assert!((dest[i] - 0.9).abs() < 1.0e-5, "{}", dest[i]); + } + + #[test] + fn tolerance_ignores_unchanged_alpha() { + // Colour moved by 0.3 and alpha did not: the 0.25 threshold must not see a 0.225 average. + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.6 }); + let dest0 = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.3 }); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + for (tolerance, paints) in [(0.25, true), (0.35, false)] { + let mut dest = dest0.clone(); + let mut p = StampParams { + dest: &mut dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src: &src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance, + tile: 0, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + let n = stamp(&mut p).unwrap(); + assert_eq!(n > 0, paints, "tolerance {tolerance}"); + } + } + + /// The Eraser keeps the colour and lowers only the alpha: an erased patch differs from the + /// history state and is painted back with a tolerance above 0. + #[test] + fn tolerance_sees_an_erased_patch() { + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |_, _, c| if c == 0 || c == 3 { 1.0 } else { 0.0 }); + let mut dest = fill(w, h, ch, |_, _, c| match c { + 0 => 1.0, + 3 => 0.6, + _ => 0.0, + }); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + let mut p = StampParams { + dest: &mut dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src: &src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.25, + tile: 0, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + assert!(stamp(&mut p).unwrap() > 0, "alpha 1 -> 0.6 is over tolerance 0.25"); + let i = (24 * w + 24) * ch; + assert!((dest[i + 3] - 1.0).abs() < 1.0e-5, "alpha restored, got {}", dest[i + 3]); + } + #[test] + fn empty_or_mismatched_buffers_error() { + let mut dest = vec![0.0f32; 16]; + let src = vec![0.0f32; 16]; + let dab = Dab { x: 1.0, y: 1.0, theta: 0.0, radius: 1.0, pressure: 1.0 }; + let mut p = StampParams { + dest: &mut dest, + dest_w: 0, + dest_h: 4, + dest_origin: (0, 0), + src: &src, + src_w: 4, + src_h: 4, + src_origin: (0, 0), + ch: 1, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile: 0, + selection: None, + lock_transparent: false, + alpha: None, + }; + assert_eq!(stamp(&mut p), Err(StampError::Empty)); + let mut dest = vec![0.0f32; 16]; + let src = vec![0.0f32; 8]; + let mut p = StampParams { + dest: &mut dest, + dest_w: 4, + dest_h: 4, + dest_origin: (0, 0), + src: &src, + src_w: 2, + src_h: 2, + src_origin: (0, 0), + ch: 4, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile: 0, + selection: None, + lock_transparent: false, + alpha: None, + }; + assert!(stamp(&mut p).is_err()); + assert!(place_dabs(&[], 10.0, 50.0, Style::TightMedium, 0).is_err()); + assert!(Style::parse("nope").is_none()); + assert_eq!(Style::parse("tightMedium"), Some(Style::TightMedium)); + } + + #[test] + fn two_stamps_with_the_same_seed_match() { + let pts = horizontal(50.0); + let dabs = place_dabs(&pts, 12.0, 50.0, Style::LooseCurl, RNG_SEED).unwrap(); + let (w, h, ch) = (80usize, 64usize, 4); + let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { (x + 3 * y) as f32 / 200.0 }); + let dest0 = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.2 }); + let run = |dest: &mut [f32]| { + let mut p = StampParams { + dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src: &src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: &dabs, + hardness: 0.8, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile: 64, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + stamp(&mut p).unwrap() + }; + let mut a = dest0.clone(); + let mut b = dest0; + assert_eq!(run(&mut a), run(&mut b)); + assert_eq!(a, b); + } +} diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index 3cadd1d20c..5bd0c8ab06 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -56,8 +56,11 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> { use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear}; mod local_tone; +mod per_pixel; mod whites_blacks; +pub use per_pixel::{CURVE_LUT, Grade, Grain, PerPixel, Vignette, VignetteStyle, WB_STEPS, WhitesBlacks, per_pixel, white_black_points}; + /// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8, /// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it. #[derive(Clone, Copy, Debug, PartialEq)] @@ -166,6 +169,13 @@ pub struct CameraRaw { /// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8 /// for ProPhoto). Set from the document's profile; Whites and Blacks act on light. pub encoding_gamma: f32, + // Calibration (Camera Raw's Calibration panel) + /// Shadows › Tint: −100 (green) … 100 (magenta), strongest in the darkest tones. + pub shadow_tint: f32, + /// Red, Green and Blue Primary › Hue: −100 … 100, the primary turned towards its neighbours. + pub calibration_hue: [f32; 3], + /// Red, Green and Blue Primary › Saturation: −100 (grey) … 100 (purer). + pub calibration_sat: [f32; 3], } impl Default for CameraRaw { @@ -222,6 +232,9 @@ impl Default for CameraRaw { seed: 0, pixel_scale: 1.0, encoding_gamma: 0.0, + shadow_tint: 0.0, + calibration_hue: [0.0; 3], + calibration_sat: [0.0; 3], } } } @@ -229,6 +242,90 @@ impl Default for CameraRaw { /// Hue band centres of the Color Mixer (degrees). pub const HSL_BANDS: [f32; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0]; +/// How far a primary turns at Hue ±100 (degrees), as the Color Mixer's hue sliders do. +const CALIBRATION_TURN: f32 = 30.0; + +/// How much of each primary (red, green, blue) a hue is made of: triangular weights around +/// 0°, 120° and 240° that sum to one, so a colour between two primaries follows both. +pub fn calibration_weights(hue: f32) -> [f32; 3] { + let h = hue.rem_euclid(360.0); + let mut w = [0.0f32; 3]; + for (i, centre) in [0.0f32, 120.0, 240.0].iter().enumerate() { + let d = (h - centre).abs().min(360.0 - (h - centre).abs()); + w[i] = (1.0 - d / 120.0).max(0.0); + } + let sum: f32 = w.iter().sum(); + if sum > 1e-6 { w.map(|v| v / sum) } else { [1.0, 0.0, 0.0] } +} + +/// The Calibration panel's primaries on one linear-light pixel: its hue turns by the weighted +/// Hue of the primaries it is made of (±100 is ±30°, positive towards the next primary) and its +/// saturation scales by their weighted Saturation (−100 greys it); the luminance is kept, so +/// neutrals are untouched. +fn calibrate(c: [f32; 3], hue: [f32; 3], sat: [f32; 3]) -> [f32; 3] { + let [h, s, v] = rgb_to_hsv(c.map(|x| x.max(0.0))); + if s <= 1e-5 || v <= 1e-6 { + return c; + } + let w = calibration_weights(h); + let (mut dh, mut ds) = (0.0f32, 0.0f32); + for i in 0..3 { + dh += w[i] * hue[i].clamp(-100.0, 100.0); + ds += w[i] * sat[i].clamp(-100.0, 100.0); + } + let nh = h + dh / 100.0 * CALIBRATION_TURN; + let ns = (s * (1.0 + ds / 100.0)).clamp(0.0, 1.0); + let out = hsv_to_rgb(nh, ns, v); + let (l0, l1) = (luma(c), luma(out)); + if l1 > 1e-6 { out.map(|x| x * l0 / l1) } else { out } +} + +/// Shadows › Tint on one linear-light pixel: `tint` −1 (green) … 1 (magenta), weighted by how +/// dark the pixel is; the luminance is kept. +fn shadow_tint(c: [f32; 3], tint: f32) -> [f32; 3] { + let l = luma(c).max(0.0); + // Linear 0.18 is middle grey: full strength below it, fading out towards white. + let weight = (1.0 - (l / 0.18).sqrt().min(1.0)).powi(2) * 0.5 + (1.0 - l.min(1.0)).powi(4) * 0.5; + let k = tint * 0.12 * weight * (l + 0.02); + let o = [c[0] + k, c[1] - k, c[2] + k].map(|v| v.max(0.0)); + let l2 = luma(o); + if l2 > 1e-6 { o.map(|v| v * l / l2) } else { o } +} + +/// Camera Raw's Auto for the Light panel: Exposure, Contrast, Highlights, Shadows, Whites and +/// Blacks from the image's luminance histogram (`px` in the document's encoding). A well +/// exposed image gets near-zero settings; a dark one more exposure and lifted shadows; one +/// with a crushed or blown end gets Blacks or Whites back from the edge. The other panels are +/// left alone, as Camera Raw's Auto leaves them. +pub fn auto_tone(px: &[[f32; 4]], p: &mut CameraRaw) { + if px.is_empty() { + return; + } + let mut lum: Vec = px.iter().map(|q| luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)).collect(); + lum.sort_by(|a, b| a.total_cmp(b)); + let at = |f: f32| lum[((lum.len() - 1) as f32 * f).round() as usize]; + let (black, shadow, median, light, white) = (at(0.001), at(0.1), at(0.5), at(0.9), at(0.999)); + // Exposure: the median towards middle grey (sRGB 0.46), damped so flat scenes aren't + // forced to it. + let median_lin = srgb_to_linear(median).max(1e-4); + p.exposure = (0.6 * (0.18f32 / median_lin).log2()).clamp(-2.0, 2.0); + // Blacks and Whites bring the ends to the edges without clipping more than Camera Raw does. + p.blacks = ((0.04 - black) * 400.0).clamp(-40.0, 40.0); + p.whites = ((0.96 - white) * 300.0).clamp(-40.0, 40.0); + // Shadows up when the darker tenth is crushed, highlights down when the brighter tenth is + // near white. + p.shadows = ((0.25 - shadow) * 200.0).clamp(0.0, 50.0); + p.highlights = ((light - 0.85) * -300.0).clamp(-50.0, 0.0); + // A little contrast for a flat histogram. + let spread = (light - shadow).max(0.0); + p.contrast = ((0.6 - spread) * 60.0).clamp(0.0, 25.0); + // Shown as Camera Raw shows them: Exposure to a hundredth of a stop, the others whole. + p.exposure = (p.exposure * 100.0).round() / 100.0; + for v in [&mut p.blacks, &mut p.whites, &mut p.shadows, &mut p.highlights, &mut p.contrast] { + *v = v.round(); + } +} + fn luma(c: [f32; 3]) -> f32 { 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] } @@ -534,6 +631,10 @@ impl CameraRaw { fn grade_neutral(&self) -> bool { self.grade_shadows.neutral() && self.grade_midtones.neutral() && self.grade_highlights.neutral() && self.grade_global.neutral() } + /// The Calibration panel at rest. + fn calibration_neutral(&self) -> bool { + self.shadow_tint == 0.0 && self.calibration_hue.iter().chain(&self.calibration_sat).all(|v| *v == 0.0) + } fn curves_neutral(&self) -> bool { [self.curve_highlights, self.curve_lights, self.curve_darks, self.curve_shadows].iter().all(|v| *v == 0.0) && [&self.point_curve, &self.point_curve_red, &self.point_curve_green, &self.point_curve_blue].iter().all(|c| is_linear(c)) @@ -541,6 +642,7 @@ impl CameraRaw { /// True when every control is neutral (the filter is an identity). pub fn is_identity(&self) -> bool { self.wb_neutral() + && self.calibration_neutral() && self.tone_neutral() && self.dehaze == 0.0 && self.vibrance == 0.0 @@ -580,14 +682,27 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let ps = p.pixel_scale.clamp(0.01, 1.0); let long = w.max(h) as f32; // 1. White balance + exposure in linear light. - if !p.wb_neutral() { - let mut g = white_balance_gains(p.temperature, p.tint); - let norm = luma(g); - g = g.map(|v| v / norm * 2f32.powf(p.exposure)); + if let Some(g) = per_pixel::gains(p) { par_rows(px, w, 1, |_, row| { for q in row.iter_mut() { - for c in 0..3 { - q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0)); + *q = balance(*q, g); + } + }); + } + // 1b. Calibration: the primaries turned and purer or greyer, and the shadows tinted, in + // linear light (the camera's primaries are what Camera Raw's panel calibrates). + if !p.calibration_neutral() { + let primaries = p.calibration_hue.iter().chain(&p.calibration_sat).any(|v| *v != 0.0); + let tint = p.shadow_tint.clamp(-100.0, 100.0) / 100.0; + par_rows(px, w, 1, |_, row| { + for q in row.iter_mut() { + let c = [srgb_to_linear(q[0]), srgb_to_linear(q[1]), srgb_to_linear(q[2])]; + let mut o = if primaries { calibrate(c, p.calibration_hue, p.calibration_sat) } else { c }; + if tint != 0.0 { + o = shadow_tint(o, tint); + } + for k in 0..3 { + q[k] = linear_to_srgb(o[k].max(0.0)); } } }); @@ -606,7 +721,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005)); let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps))); let k_con = p.contrast / 100.0 * 0.6; - let tau = std::f32::consts::TAU; par_rows(px, w, 1, |y, row| { for (x, q) in row.iter_mut().enumerate() { let i = y * w + x; @@ -619,22 +733,14 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo if let Some(t) = &tex { lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1); } - if k_con != 0.0 { - let xx = lv.clamp(0.0, 1.0); - lv += -k_con * (tau * xx).sin() / tau; - } - let lv = lv.max(0.0) + (l0 - lc[i]); - let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 }; - for c in 0..3 { - q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) }; - } + *q = tone(*q, l0, lc[i], lv, k_con); } }); } // 3b. Whites and Blacks: global curves adapted to the image's black and white points. if p.whites != 0.0 || p.blacks != 0.0 { let enc = Encoding::of(p.encoding_gamma); - let (bp, wp) = luminance_points(px, w, h, enc); + let (bp, wp) = luminance_points(px, w, h, enc, |q| q); let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp); whites_blacks::apply(px, w, xw, xb, enc); } @@ -646,14 +752,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let (master, cr, cg, cb) = (pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue)); let hsl = !p.hsl_neutral(); let grade = !p.grade_neutral(); - let wheel_tint = |wh: &Wheel| -> [f32; 3] { - let c = hsv_to_rgb(wh.hue, 1.0, 1.0); - let l = luma(c); - [c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3) - }; - let tints = [wheel_tint(&p.grade_shadows), wheel_tint(&p.grade_midtones), wheel_tint(&p.grade_highlights), wheel_tint(&p.grade_global)]; - let blend = 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35; - let bal = p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25; + let Grade { tints, lums, blend, balance: bal } = Grade::new(p); par_rows(px, w, 1, |_, row| { for q in row.iter_mut() { let mut c = [q[0], q[1], q[2]]; @@ -706,7 +805,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let hi = smoothstep(0.5 + bal - blend, 1.0, l); let mid = (1.0 - sh - hi).max(0.0); let masks = [sh, mid, hi, 1.0]; - let lums = [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum]; for k in 0..4 { for ch in 0..3 { c[ch] += masks[k] * tints[k][ch]; @@ -732,11 +830,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo vignette(px, w, h, p); } if p.grain_amount > 0.0 { - let cell = 1.0 + p.grain_size / 25.0; - let rough = p.grain_roughness / 100.0; - let amt = p.grain_amount / 100.0 * 0.12; - let seed = p.seed as u64; - let pscale = 1.0 / ps; + let Grain { cell, roughness: rough, amount: amt, seed, scale: pscale } = Grain::new(p); par_rows(px, w, 1, |y, row| { for (x, q) in row.iter_mut().enumerate() { // Sample in full-resolution coordinates so previews match the result. @@ -759,24 +853,60 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo } } +/// The end of stage 3 on one pixel: Contrast (strength `k_con`) on the luminance `lv` that +/// Clarity and Texture left, then the pixel rescaled to it. `l0` is its luminance, `lc` that +/// clipped to 1. +fn tone(mut q: [f32; 4], l0: f32, lc: f32, mut lv: f32, k_con: f32) -> [f32; 4] { + if k_con != 0.0 { + let tau = std::f32::consts::TAU; + let xx = lv.clamp(0.0, 1.0); + lv += -k_con * (tau * xx).sin() / tau; + } + let lv = lv.max(0.0) + (l0 - lc); + let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 }; + for c in 0..3 { + q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) }; + } + q +} + +/// Stage 3 on one pixel when Contrast is its only control. +fn contrast(q: [f32; 4], k_con: f32) -> [f32; 4] { + let l0 = luma([q[0], q[1], q[2]]).max(0.0); + let lc = l0.min(1.0); + tone(q, l0, lc, lc, k_con) +} + +/// Stage 1 on one pixel: white balance and exposure `gains` in linear light. +fn balance(mut q: [f32; 4], g: [f32; 3]) -> [f32; 4] { + for c in 0..3 { + q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0)); + } + q +} + /// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the /// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a -/// preview proxy and the full image agree). -fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) { +/// preview proxy and the full image agree). `map` is applied to each pixel first. +fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding, map: impl Fn([f32; 4]) -> [f32; 4] + Sync) -> (f32, f32) { let f = w.max(h).div_ceil(256).max(1); let (sw, sh) = (w.div_ceil(f), h.div_ceil(f)); - let mut sum = vec![[0.0f64; 4]; sw * sh]; - for y in 0..h { - for x in 0..w { - let q = px[y * w + x]; - let s = &mut sum[(y / f) * sw + x / f]; - for c in 0..3 { - s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0)); + // Each band of `f` rows sums into its own row of cells, in the same order as one pass would. + let bands = crate::photo_util::par_map(sh, |sy| { + let mut sum = vec![[0.0f64; 4]; sw]; + for y in sy * f..((sy + 1) * f).min(h) { + for x in 0..w { + let q = map(px[y * w + x]); + let s = &mut sum[x / f]; + for c in 0..3 { + s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0)); + } + s[3] += 1.0; } - s[3] += 1.0; } - } - let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect(); + sum + }); + let cells: Vec<[f32; 3]> = bands.iter().flatten().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect(); if cells.is_empty() { return (0.0, 1.0); } @@ -818,23 +948,25 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) { *s = s.map(|v| v / c.max(1.0)); } let r = (sw.max(sh) / 60).max(2); - let min_filter = |v: &[f32]| -> Vec { + // The minimum (or, with `f32::max`, maximum) over each (2r + 1)² window. + let rank_filter = |v: &[f32], start: f32, pick: fn(f32, f32) -> f32| -> Vec { let mut a = vec![0.0f32; sw * sh]; for y in 0..sh { for x in 0..sw { let (x0, x1) = (x.saturating_sub(r), (x + r).min(sw - 1)); - a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(f32::MAX, f32::min); + a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(start, pick); } } let mut b = vec![0.0f32; sw * sh]; for y in 0..sh { for x in 0..sw { let (y0, y1) = (y.saturating_sub(r), (y + r).min(sh - 1)); - b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(f32::MAX, f32::min); + b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(start, pick); } } b }; + let min_filter = |v: &[f32]| rank_filter(v, f32::MAX, f32::min); let dark = min_filter(&small.iter().map(|c| c[0].min(c[1]).min(c[2])).collect::>()); // Atmospheric light: mean colour of the brightest 0.1 % of the dark channel. let mut idx: Vec = (0..sw * sh).collect(); @@ -848,7 +980,15 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) { } let air = air.map(|v| v.max(0.2)); let norm_dark = min_filter(&small.iter().map(|c| (c[0] / air[0]).min(c[1] / air[1]).min(c[2] / air[2])).collect::>()); - let t_raw: Vec = norm_dark.iter().map(|d| 1.0 - 0.95 * d).collect(); + // Opened (a maximum filter of the same radius after the minimum): the minimum grows every + // dark object by `r`, so sky beside a dark building took its dark channel, counted as + // haze-free and kept a bright halo (#9273). Opening shrinks dark regions back to their + // outline and is still a lower bound of the dark channel over the window. It raises the dark + // channel on average, so the 0.95 weight is scaled to keep the slider's overall strength. + let opened = rank_filter(&norm_dark, f32::MIN, f32::max); + let mean = |v: &[f32]| v.iter().sum::() / v.len().max(1) as f32; + let weight = 0.95 * mean(&norm_dark) / mean(&opened).max(1e-6); + let t_raw: Vec = opened.iter().map(|d| 1.0 - weight * d).collect(); let guide: Vec = small.iter().map(|c| luma(*c)).collect(); let t = guided(&guide, &t_raw, sw, sh, r * 2, 1e-3); let ks = k as f32; @@ -923,24 +1063,7 @@ fn detail(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, ps: f32) { } fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) { - let amt = p.vignette_amount / 100.0; - let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0; - let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0; - let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0; - let hl = p.vignette_highlights.clamp(0.0, 100.0) / 100.0; - let style = p.vignette_style.as_str(); - let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); - // Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle. - let (ax, ay) = if round >= 0.0 { - let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round); - (if cx >= cy { r } else { cx }, if cy > cx { r } else { cy }) - } else { - (cx, cy) - }; - let pw = 2.0 + (-round).max(0.0) * 6.0; - let inner = 1.0 - mid * 0.8; - let edge0 = inner - feather * 0.5 * inner; - let edge1 = inner + feather * 0.5 * (1.4 - inner) + 0.02; + let Vignette { amount: amt, highlights: hl, style, centre: [cx, cy], axes: [ax, ay], power: pw, edges: [edge0, edge1] } = Vignette::new(w, h, p); par_rows(px, w, 1, |y, row| { for (x, q) in row.iter_mut().enumerate() { let (dx, dy) = (((x as f32 + 0.5 - cx) / ax).abs(), ((y as f32 + 0.5 - cy) / ay).abs()); @@ -951,7 +1074,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) { } let l = luma([q[0], q[1], q[2]]).clamp(0.0, 1.0); match style { - "paintOverlay" => { + VignetteStyle::PaintOverlay => { let target = if amt < 0.0 { 0.0 } else { 1.0 }; let k = m * amt.abs(); for c in 0..3 { @@ -965,7 +1088,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) { let protect = smoothstep(0.6, 1.0, l) * hl; k += (1.0 - k) * protect; } - if style == "colorPriority" { + if style == VignetteStyle::ColorPriority { let nl = l * k; for c in 0..3 { q[c] += nl - l; @@ -985,6 +1108,37 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) { mod tests { use super::*; + /// #9273: Dehaze clears the haze evenly over a sky, right up to a dark building, instead of + /// leaving a bright halo of uncorrected haze around it. Synthetic scene: a clear sky and a + /// dark tower, hazed with `I = J·t + A·(1 − t)`. + #[test] + fn dehaze_leaves_no_halo_beside_dark_objects() { + let (w, h) = (600usize, 400usize); + let tower = |x: usize, y: usize| (250..350).contains(&x) && y >= 120; + let (t, air) = (0.55f32, [0.9f32, 0.9, 0.9]); + let mut px: Vec<[f32; 4]> = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let j = if tower(x, y) { [0.06, 0.06, 0.07] } else { [0.35 + 0.1 * y as f32 / h as f32, 0.5, 0.8] }; + let c: [f32; 3] = std::array::from_fn(|k| j[k] * t + air[k] * (1.0 - t)); + [c[0], c[1], c[2], 1.0] + }) + .collect(); + dehaze(&mut px, w, h, 1.0); + let sky = |xs: std::ops::Range| { + let v: Vec = + (150..390).flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| luma([px[y * w + x][0], px[y * w + x][1], px[y * w + x][2]])).collect(); + v.iter().sum::() / v.len() as f32 + }; + // Sky 1–8 px from the tower against open sky: the haze left beside it, in 8-bit levels. + let halo = (sky(242..250) - sky(20..80)) * 255.0; + // 6.4 levels before the dark channel was opened, under 1 after. + assert!(halo < 3.0, "a {halo:.1}-level halo beside the tower"); + // The open sky is still cleared: darker than the hazy input. + let hazy = luma(std::array::from_fn(|k| [0.4, 0.5, 0.8][k] * t + air[k] * (1.0 - t))); + assert!(sky(20..80) < hazy - 0.02, "{} vs hazy {hazy}", sky(20..80)); + } + #[test] fn new_curves_are_validated_but_saved_curves_still_load_and_render() { for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] { @@ -1144,6 +1298,105 @@ mod tests { assert!(v[0][0] < 0.35 && (v[(h / 2) * w + w / 2][0] - 0.5).abs() < 0.01, "{:?}", v[0]); } + #[test] + fn calibration_keeps_the_develop_off_the_per_pixel_gpu_stages() { + // The per-pixel stages (the Camera Raw dialog's GPU preview) don't run Calibration, so + // settings with it must fall back to the CPU develop instead of previewing without it. + for p in [ + CameraRaw { shadow_tint: 40.0, ..Default::default() }, + CameraRaw { calibration_hue: [0.0, 0.0, 30.0], whites: 20.0, ..Default::default() }, + CameraRaw { calibration_sat: [-50.0, 0.0, 0.0], exposure: 0.5, ..Default::default() }, + ] { + assert!(!p.spatial()); + assert!(per_pixel(&p, 2, 2, false, Some((0.0, 1.0))).is_none(), "{p:?}"); + } + assert!(per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 2, 2, false, None).is_some()); + } + + #[test] + fn calibration_turns_primaries_keeps_neutrals_and_tints_shadows() { + let w = calibration_weights(0.0); + assert!((w[0] - 1.0).abs() < 1e-6, "pure red is all red primary: {w:?}"); + let w = calibration_weights(60.0); + assert!((w[0] - 0.5).abs() < 1e-6 && (w[1] - 0.5).abs() < 1e-6, "yellow is half red, half green: {w:?}"); + let w = calibration_weights(300.0); + assert!((w[2] - 0.5).abs() < 1e-6 && (w[0] - 0.5).abs() < 1e-6, "magenta wraps round: {w:?}"); + let (none, zero) = ([0.0f32; 3], [0.0f32; 3]); + let grey = calibrate([0.3, 0.3, 0.3], [100.0, -100.0, 50.0], [100.0, -100.0, 100.0]); + assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-5), "greys are untouched: {grey:?}"); + // Red turned towards green: it gains green, keeps its luminance. + let red = calibrate([0.5, 0.0, 0.0], [100.0, 0.0, 0.0], zero); + assert!(red[1] > 0.05 && (red[1] / red[0] - 0.5).abs() < 0.02 && red[2] < 1e-6, "red turns 30° towards green (orange): {red:?}"); + assert!((luma(red) - luma([0.5, 0.0, 0.0])).abs() < 1e-4, "luminance kept: {red:?}"); + // Blue at saturation −100 is grey; green at −50 is paler. + let blue = calibrate([0.0, 0.0, 0.8], none, [0.0, 0.0, -100.0]); + assert!((blue[0] - blue[1]).abs() < 1e-5 && (blue[1] - blue[2]).abs() < 1e-5, "blue at −100 saturation is grey: {blue:?}"); + let green = calibrate([0.2, 0.8, 0.2], none, [0.0, -50.0, 0.0]); + assert!(green[0] > 0.2 && green[1] < 0.8, "green at −50 is paler: {green:?}"); + // A primary's sliders leave the other primaries alone. + let unchanged = calibrate([0.0, 0.0, 0.8], [100.0, 100.0, 0.0], [100.0, 100.0, 0.0]); + assert!(unchanged.iter().zip([0.0, 0.0, 0.8]).all(|(a, b)| (a - b).abs() < 1e-5), "{unchanged:?}"); + // Shadows tint: magenta lifts red and blue over green in a dark pixel, keeps luminance, + // and leaves white alone. + let dark = shadow_tint([0.02, 0.02, 0.02], 1.0); + assert!(dark[0] > dark[1] && dark[2] > dark[1], "{dark:?}"); + assert!((luma(dark) - 0.02).abs() < 1e-4, "luminance kept: {dark:?}"); + let green = shadow_tint([0.02, 0.02, 0.02], -1.0); + assert!(green[1] > green[0], "{green:?}"); + let white = shadow_tint([1.0, 1.0, 1.0], 1.0); + assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-3), "white is not tinted: {white:?}"); + // Through develop: neutral settings are an identity, a tint changes dark pixels only. + let mut p = CameraRaw::default(); + assert!(p.is_identity()); + p.shadow_tint = 100.0; + assert!(!p.is_identity()); + let mut px = vec![[0.1, 0.1, 0.1, 1.0], [0.95, 0.95, 0.95, 1.0]]; + develop(&mut px, 2, 1, &p, false); + assert!(px[0][0] > px[0][1], "dark grey turns magenta: {:?}", px[0]); + assert!((px[1][0] - px[1][1]).abs() < 0.01, "near-white stays neutral: {:?}", px[1]); + p = CameraRaw::default(); + p.calibration_hue[2] = 100.0; + let mut px = vec![[0.0, 0.0, 0.8, 1.0]]; + develop(&mut px, 1, 1, &p, false); + assert!(px[0][0] > 0.05, "blue turned towards red: {:?}", px[0]); + } + + #[test] + fn auto_tone_brightens_dark_and_darkens_bright_images() { + let mut p = CameraRaw::default(); + auto_tone(&[], &mut p); + assert_eq!(p, CameraRaw::default(), "no pixels: nothing changes"); + let dark: Vec<[f32; 4]> = (0..4096) + .map(|i| { + let v = 0.05 + (i % 64) as f32 / 64.0 * 0.15; + [v, v, v, 1.0] + }) + .collect(); + auto_tone(&dark, &mut p); + assert!(p.exposure > 0.5, "a dark image gets exposure: {}", p.exposure); + assert!(p.shadows > 0.0, "and lifted shadows: {}", p.shadows); + let mut p = CameraRaw::default(); + let bright: Vec<[f32; 4]> = (0..4096) + .map(|i| { + let v = 0.8 + (i % 64) as f32 / 64.0 * 0.19; + [v, v, v, 1.0] + }) + .collect(); + auto_tone(&bright, &mut p); + assert!(p.exposure < -0.5, "a bright image loses exposure: {}", p.exposure); + assert!(p.highlights < 0.0, "and its highlights come down: {}", p.highlights); + let mut p = CameraRaw::default(); + let good: Vec<[f32; 4]> = (0..4096) + .map(|i| { + let v = 0.04 + (i % 64) as f32 / 63.0 * 0.92; + [v, v, v, 1.0] + }) + .collect(); + auto_tone(&good, &mut p); + assert!(p.exposure.abs() < 0.3 && p.blacks.abs() < 10.0 && p.whites.abs() < 10.0, "a full-range image is left nearly alone: {p:?}"); + assert!(p.saturation == 0.0 && p.hsl_neutral(), "other panels untouched"); + } + #[test] fn float_documents_keep_overrange() { let mut a = vec![[2.0f32, 1.5, 0.5, 1.0]; 16]; diff --git a/crates/algo/src/camera_raw/per_pixel.rs b/crates/algo/src/camera_raw/per_pixel.rs new file mode 100644 index 0000000000..6400db8a25 --- /dev/null +++ b/crates/algo/src/camera_raw/per_pixel.rs @@ -0,0 +1,240 @@ +//! Develop's per-pixel stages resolved for one image: every constant and table they use, so +//! another implementation of them (the GPU preview, `photocraft_gpu::CameraRawDevelop`) runs the +//! same maths. [`develop`](super::develop) builds its own constants with the same functions. + +use super::{CameraRaw, Encoding, Wheel, curve_lut, hsv_to_rgb, is_linear, luma, parametric_lut, white_balance_gains, whites_blacks}; + +/// Entries of the tone curve tables (parametric and point curves). +pub const CURVE_LUT: usize = 1024; +/// Samples of a Whites or Blacks curve row: grey levels 0, 1/32, …, 1. +pub const WB_STEPS: usize = whites_blacks::STEPS; + +/// White balance and exposure: channel gains in linear light, when the stage is on. +pub(crate) fn gains(p: &CameraRaw) -> Option<[f32; 3]> { + if p.wb_neutral() { + return None; + } + let g = white_balance_gains(p.temperature, p.tint); + let norm = luma(g); + Some(g.map(|v| v / norm * 2f32.powf(p.exposure))) +} + +/// Contrast's strength, when Contrast is set. +fn contrast(p: &CameraRaw) -> Option { + (p.contrast != 0.0).then_some(p.contrast / 100.0 * 0.6) +} + +/// Whites and Blacks as two curve rows for the image's amounts. +#[derive(Clone, Debug, PartialEq)] +pub struct WhitesBlacks { + /// The Whites curve over grey levels 0, 1/32, …, 1, when Whites is set. + pub whites: Option<[f32; WB_STEPS]>, + /// The Blacks curve, applied after Whites. + pub blacks: Option<[f32; WB_STEPS]>, + /// The clipping directions (Whites up, Blacks down) curve each channel; the others scale the + /// pixel by the change of its luminance. + pub per_channel: bool, +} + +/// Color Grading's wheels resolved. +#[derive(Clone, Debug, PartialEq)] +pub struct Grade { + /// Colour offsets of the shadows, midtones, highlights and global wheels. + pub tints: [[f32; 3]; 4], + /// Luminance of the same wheels (slider units). + pub lums: [f32; 4], + pub blend: f32, + pub balance: f32, +} + +impl Grade { + pub(crate) fn new(p: &CameraRaw) -> Self { + let tint = |wh: &Wheel| -> [f32; 3] { + let c = hsv_to_rgb(wh.hue, 1.0, 1.0); + let l = luma(c); + [c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3) + }; + Grade { + tints: [tint(&p.grade_shadows), tint(&p.grade_midtones), tint(&p.grade_highlights), tint(&p.grade_global)], + lums: [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum], + blend: 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35, + balance: p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25, + } + } +} + +/// The vignette's `vignette_style`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VignetteStyle { + HighlightPriority, + ColorPriority, + PaintOverlay, +} + +/// The post-crop vignette's shape on a `w × h` frame. +#[derive(Clone, Debug, PartialEq)] +pub struct Vignette { + /// −1 … 1. + pub amount: f32, + /// 0 … 1. + pub highlights: f32, + pub style: VignetteStyle, + /// Frame centre (pixels). + pub centre: [f32; 2], + /// Half axes of the shape (pixels). + pub axes: [f32; 2], + /// Exponent of the superellipse. + pub power: f32, + /// The mask's smoothstep edges in normalised radius. + pub edges: [f32; 2], +} + +impl Vignette { + pub(crate) fn new(w: usize, h: usize, p: &CameraRaw) -> Self { + let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0; + let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0; + let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0; + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + // Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle. + let (ax, ay) = if round >= 0.0 { + let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round); + (if cx >= cy { r } else { cx }, if cy > cx { r } else { cy }) + } else { + (cx, cy) + }; + let inner = 1.0 - mid * 0.8; + Vignette { + amount: p.vignette_amount / 100.0, + highlights: p.vignette_highlights.clamp(0.0, 100.0) / 100.0, + style: match p.vignette_style.as_str() { + "paintOverlay" => VignetteStyle::PaintOverlay, + "colorPriority" => VignetteStyle::ColorPriority, + _ => VignetteStyle::HighlightPriority, + }, + centre: [cx, cy], + axes: [ax, ay], + power: 2.0 + (-round).max(0.0) * 6.0, + edges: [inner - feather * 0.5 * inner, inner + feather * 0.5 * (1.4 - inner) + 0.02], + } + } +} + +/// Grain: value noise sampled in full-resolution coordinates. +#[derive(Clone, Debug, PartialEq)] +pub struct Grain { + /// Noise cell size (full-resolution pixels). + pub cell: f32, + /// Weight of the finer octave. + pub roughness: f32, + pub amount: f32, + pub seed: u64, + /// Full-resolution pixels per pixel (`1 / pixel_scale`). + pub scale: f32, +} + +impl Grain { + pub(crate) fn new(p: &CameraRaw) -> Self { + Grain { + cell: 1.0 + p.grain_size / 25.0, + roughness: p.grain_roughness / 100.0, + amount: p.grain_amount / 100.0 * 0.12, + seed: u64::from(p.seed), + scale: 1.0 / p.pixel_scale.clamp(0.01, 1.0), + } + } +} + +/// Develop's per-pixel stages for one image and settings, in order. `None` stages are off. +#[derive(Clone, Debug, PartialEq)] +pub struct PerPixel { + /// 1. White balance and exposure gains in linear light (sRGB-decoded values). + pub gains: Option<[f32; 3]>, + /// 3. Contrast's strength (Contrast without Texture or Clarity is per pixel). + pub contrast: Option, + /// 3b. Whites and Blacks, in the document's encoding. + pub whites_blacks: Option, + /// The document's transfer curve: 0 for sRGB's, else a power-law gamma. + pub encoding: f32, + /// 4. Vibrance and Saturation (slider / 100). + pub presence: Option<[f32; 2]>, + /// 5. The parametric tone curve, [`CURVE_LUT`] entries over 0 … 1. + pub parametric: Option>, + /// 5. Point curves: master, red, green, blue. + pub point: [Option>; 4], + /// 6. Color Mixer hue, saturation and luminance per band. + pub hsl: Option<[[f32; 8]; 3]>, + /// 7. Color Grading. + pub grade: Option, + /// 9. Vignette. + pub vignette: Option, + /// 9. Grain. + pub grain: Option, + /// Clip RGB to 0 … 1 at the end (all but 32-bit documents). + pub clamp: bool, +} + +impl CameraRaw { + /// Whether developing needs a stage that reads neighbouring pixels: Dehaze, Shadows and + /// Highlights, Texture, Clarity, sharpening or noise reduction. + pub fn spatial(&self) -> bool { + self.dehaze != 0.0 + || [self.highlights, self.shadows, self.texture, self.clarity].iter().any(|v| *v != 0.0) + || self.sharpen_amount > 0.0 + || self.noise_luminance > 0.0 + || self.noise_color > 0.0 + } +} + +/// The image's black and white points that Whites and Blacks adapt to, for `px` (`w × h`, +/// display-encoded) developed per pixel with `p`: what [`develop`](super::develop) measures +/// after white balance and Contrast. `None` when Whites and Blacks are 0 or `p` needs a +/// spatial stage. +pub fn white_black_points(px: &[[f32; 4]], w: usize, h: usize, p: &CameraRaw) -> Option<(f32, f32)> { + if (p.whites == 0.0 && p.blacks == 0.0) || p.spatial() || px.len() != w.saturating_mul(h) { + return None; + } + let enc = Encoding::of(p.encoding_gamma); + let (g, k_con) = (gains(p), contrast(p)); + Some(super::luminance_points(px, w, h, enc, |q| { + let q = g.map_or(q, |g| super::balance(q, g)); + k_con.map_or(q, |k| super::contrast(q, k)) + })) +} + +/// Develop's stages for settings without spatial stages, on a `w × h` image; `points` are the +/// image's [`white_black_points`]. `None` when `p` needs a spatial stage or Calibration (these +/// stages don't run it), or Whites or Blacks is set without `points`. The points themselves +/// leave Calibration out, so a caller may cache them across Calibration changes. +pub fn per_pixel(p: &CameraRaw, w: usize, h: usize, float: bool, points: Option<(f32, f32)>) -> Option { + if p.spatial() || !p.calibration_neutral() { + return None; + } + let identity = p.is_identity(); + let whites_blacks = if p.whites != 0.0 || p.blacks != 0.0 { + let (bp, wp) = points?; + let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp); + (xw != 0.0 || xb != 0.0).then(|| WhitesBlacks { + whites: (xw != 0.0).then(|| whites_blacks::whites_row(xw)), + blacks: (xb != 0.0).then(|| whites_blacks::blacks_row(xb)), + per_channel: xw > 0.0 || xb < 0.0, + }) + } else { + None + }; + let parametric_on = !(p.curve_highlights == 0.0 && p.curve_lights == 0.0 && p.curve_darks == 0.0 && p.curve_shadows == 0.0); + let pts = |c: &Vec<[f32; 2]>| (!is_linear(c)).then(|| curve_lut(c, CURVE_LUT)); + Some(PerPixel { + gains: gains(p), + contrast: contrast(p), + whites_blacks, + encoding: Encoding::of(p.encoding_gamma).0, + presence: (p.vibrance != 0.0 || p.saturation != 0.0).then_some([p.vibrance / 100.0, p.saturation / 100.0]), + parametric: parametric_on.then(|| parametric_lut(p, CURVE_LUT)), + point: [pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue)], + hsl: (!p.hsl_neutral()).then_some([p.hsl_hue, p.hsl_sat, p.hsl_lum]), + grade: (!p.grade_neutral()).then(|| Grade::new(p)), + vignette: (p.vignette_amount != 0.0).then(|| Vignette::new(w, h, p)), + grain: (p.grain_amount > 0.0).then(|| Grain::new(p)), + clamp: !float && !identity, + }) +} diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs index 93bb9cff31..cfce5378b4 100644 --- a/crates/algo/src/camera_raw/whites_blacks.rs +++ b/crates/algo/src/camera_raw/whites_blacks.rs @@ -24,7 +24,7 @@ use super::Encoding; use crate::photo_util::par_rows; /// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded). -const STEPS: usize = 33; +pub(crate) const STEPS: usize = 33; /// The first row's internal amount; rows are 10 apart. const WHITES_FROM: f32 = -100.0; const BLACKS_FROM: f32 = -100.0; @@ -315,6 +315,25 @@ fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 { at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t } +/// The curve at a fractional internal amount `x` as one row: `curve(rows, from, x, v)` is this +/// row interpolated at `v`, because both interpolations are linear. +fn row_at(rows: &[[f32; STEPS]], from: f32, x: f32) -> [f32; STEPS] { + let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32); + let i = (r as usize).min(rows.len() - 2); + let t = r - i as f32; + std::array::from_fn(|j| rows[i][j] * (1.0 - t) + rows[i + 1][j] * t) +} + +/// The Whites curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1. +pub(crate) fn whites_row(x: f32) -> [f32; STEPS] { + row_at(&WHITES, WHITES_FROM, x) +} + +/// The Blacks curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1. +pub(crate) fn blacks_row(x: f32) -> [f32; STEPS] { + row_at(&BLACKS, BLACKS_FROM, x) +} + /// Whites at internal amount `x` (−100 … 270) of grey level `v`. pub(crate) fn whites(v: f32, x: f32) -> f32 { curve(&WHITES, WHITES_FROM, x, v) diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index 981e854cc5..84b15a215b 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -114,6 +114,21 @@ struct Variant { ok: Vec, } +/// Most pixels the mosaic of the image and its variants may span. It is one dense rectangle, so +/// thin images turned to different angles leave it mostly empty: the cost grows with the square +/// of their length, not with their pixels. +const MAX_MOSAIC_PIXELS: usize = 1 << 25; + +/// Size of the mosaic of an `w`×`h` image and `extra` variant sizes separated by `gap`, or `None` +/// when it spans more than [`MAX_MOSAIC_PIXELS`] (or overflows). +fn mosaic_size(w: usize, h: usize, gap: usize, extra: &[(usize, usize)]) -> Option<(usize, usize)> { + let col_w = extra.iter().map(|v| v.0).max().unwrap_or(0); + let col_h = extra.iter().try_fold(0usize, |n, v| n.checked_add(v.1)?.checked_add(gap))?; + let mw = w.checked_add(gap)?.checked_add(col_w)?; + let mh = h.max(col_h); + (mw.checked_mul(mh)? <= MAX_MOSAIC_PIXELS).then_some((mw, mh)) +} + /// A source buffer and which of its pixels may be sampled. #[derive(Clone, Copy)] struct Src<'a> { @@ -149,13 +164,20 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool { /// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror. /// `Err` when cancelled. -fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result { - let Src { w, h, ch, .. } = src; +/// Dimensions of the [`variant`] copy: the same bounding-box maths without building its pixels. +fn variant_dims(w: usize, h: usize, deg: f32, scale: f32) -> (usize, usize) { let (s, c) = (-deg.to_radians()).sin_cos(); - // Output size: the rotated, scaled bounding box. let (fw, fh) = (w as f32 * scale, h as f32 * scale); let ow = ((fw * c.abs() + fh * s.abs()).round() as usize).max(1); let oh = ((fw * s.abs() + fh * c.abs()).round() as usize).max(1); + (ow, oh) +} + +fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result { + let Src { w, h, ch, .. } = src; + // Output size: the rotated, scaled bounding box. + let (ow, oh) = variant_dims(w, h, deg, scale); + let (s, c) = (-deg.to_radians()).sin_cos(); let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0); let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0); let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] }; @@ -243,17 +265,27 @@ fn fill_cow( let gap = 2 * params.patch_radius + 2; let ok: Vec = par::map(w * h, true, |i| !hole[i] && source[i]); let src = Src { w, h, ch, img: &img, ok: &ok }; - // Variants: the identity (which holds the hole) first, then the adaptations. - let mut extra: Vec = Vec::new(); + // Variants: the identity (which holds the hole) first, then the adaptations. Their pixels are + // expensive, so the mosaic budget is settled on their dimensions before any variant is built; + // when the variants would not fit, the image itself is completed without them. + let mut specs: Vec<(f32, f32, bool)> = Vec::new(); if opts.mirror { - extra.push(variant(src, 0.0, 1.0, true, ctl)?); + specs.push((0.0, 1.0, true)); } for ° in &opts.rotations { - extra.push(variant(src, deg, 1.0, false, ctl)?); + specs.push((deg, 1.0, false)); } if opts.scale { - extra.push(variant(src, 0.0, 0.8, false, ctl)?); - extra.push(variant(src, 0.0, 1.25, false, ctl)?); + specs.push((0.0, 0.8, false)); + specs.push((0.0, 1.25, false)); + } + let dims: Vec<(usize, usize)> = specs.iter().map(|&(deg, scale, _)| variant_dims(w, h, deg, scale)).collect(); + let size = if specs.is_empty() { None } else { mosaic_size(w, h, gap, &dims) }; + let mut extra: Vec = Vec::new(); + if size.is_some() { + for &(deg, scale, mirror) in &specs { + extra.push(variant(src, deg, scale, mirror, ctl)?); + } } let role = |s: usize| { if hole[s] { @@ -265,14 +297,9 @@ fn fill_cow( } }; // Mosaic: identity at the left, variants in a column to its right, gaps that are no image. - // Without variants it is the image itself. - let (mw, mh, mosaic, roles): (usize, usize, Option>, Vec) = if extra.is_empty() { - (w, h, None, par::map(w * h, true, role)) - } else { - let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0); - let col_h: usize = extra.iter().map(|v| v.h + gap).sum(); - let mw = w + gap + col_w; - let mh = h.max(col_h); + // Without variants it is the image itself; so it is when the variants would not fit + // (`size` above is then None and `extra` is empty). + let (mw, mh, mosaic, roles): (usize, usize, Option>, Vec) = if let Some((mw, mh)) = size { let mut mimg = vec![0.0f32; mw * mh * ch]; let mut roles = vec![Px::Void; mw * mh]; for y in 0..h { @@ -297,6 +324,8 @@ fn fill_cow( oy += v.h + gap; } (mw, mh, Some(mimg), roles) + } else { + (w, h, None, par::map(w * h, true, role)) }; drop((extra, ok)); ctl.check()?; @@ -695,6 +724,20 @@ pub fn default_brush_size(hole: Rect) -> f64 { #[cfg(test)] mod tests { + use super::mosaic_size; + + fn blank(w: usize, h: usize) -> (usize, usize) { + (w, h) + } + + #[test] + fn a_thin_image_with_turned_variants_does_not_get_a_quadratic_mosaic() { + // 40000×1 plus its 90° copy (1×40000): about 1.6 billion mosaic cells for 80000 pixels. + assert_eq!(mosaic_size(40_000, 1, 8, &[blank(1, 40_000)]), None); + assert_eq!(mosaic_size(usize::MAX, 1, 8, &[blank(1, 1)]), None); + assert_eq!(mosaic_size(100, 100, 8, &[blank(100, 100)]), Some((208, 108))); + } + use super::*; /// Vertical stripes (period 6) in grey, with a hole in the middle. diff --git a/crates/algo/src/distort.rs b/crates/algo/src/distort.rs index fb6e3e69c3..4f8356f60d 100644 --- a/crates/algo/src/distort.rs +++ b/crates/algo/src/distort.rs @@ -112,6 +112,10 @@ pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: Sph }) } +/// The most Wave generators the renderer models: it clamps the setting to this, so a file asking +/// for more (the command allows up to 999) would render differently from how it was authored. +pub const MAX_WAVE_GENERATORS: u32 = 64; + /// Wave generator settings. pub(crate) struct WaveSpec { pub generators: u32, @@ -137,8 +141,9 @@ fn shape(t: WaveType, phase: f32) -> f32 { } } +/// Wave: displace `src` over `out` by the average of `w.generators` seeded waves (at most `MAX_WAVE_GENERATORS`) along each axis. pub(crate) fn wave(src: &Image, out: Rect, ctx: &Ctx, w: WaveSpec) -> Vec { - let n = w.generators.clamp(1, 64); + let n = w.generators.clamp(1, MAX_WAVE_GENERATORS); let lerp = |(a, b): (f32, f32), t: f32| a + (b - a).max(0.0) * t; let gens: Vec<(f32, f32, f32, f32)> = (0..n) .map(|i| { diff --git a/crates/algo/src/exif.rs b/crates/algo/src/exif.rs index d18bcdec9e..15c81fbe9c 100644 --- a/crates/algo/src/exif.rs +++ b/crates/algo/src/exif.rs @@ -87,6 +87,8 @@ impl Tiff<'_> { } } fn text(&self, typ: u16, count: u32, field: usize) -> Option { + #[cfg(test)] + TEXT_DECODES.with(|c| c.set(c.get() + 1)); if typ != 2 { return None; } @@ -97,6 +99,9 @@ impl Tiff<'_> { } } +#[cfg(test)] +thread_local!(static TEXT_DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }); + /// Reads [`CameraInfo`] from a TIFF-structured EXIF payload (an `Exif\0\0` prefix is skipped). pub fn read(exif: &[u8]) -> CameraInfo { let b = exif.strip_prefix(b"Exif\0\0").unwrap_or(exif); @@ -117,15 +122,17 @@ pub fn read(exif: &[u8]) -> CameraInfo { seen.push(off); for (tag, typ, count, field) in t.ifd(off) { match tag { - 0x010F => info.make = t.text(typ, count, field), - 0x0110 => info.model = t.text(typ, count, field), + // Duplicate TIFF text tags can point at the same large payload. Keep the first + // recognized value and do not repeatedly decode/copy later duplicates. + 0x010F if info.make.is_none() => info.make = t.text(typ, count, field), + 0x0110 if info.model.is_none() => info.model = t.text(typ, count, field), 0x829A => info.exposure_time = t.number(typ, count, field), 0x829D => info.f_number = t.number(typ, count, field), 0x8827 => info.iso = t.number(typ, count, field), 0x920A => info.focal_length = t.number(typ, count, field), 0xA405 => info.focal_length_35mm = t.number(typ, count, field).filter(|v| *v > 0.0), - 0xA434 => info.lens = t.text(typ, count, field), - 0x9003 => info.date_taken = t.text(typ, count, field), + 0xA434 if info.lens.is_none() => info.lens = t.text(typ, count, field), + 0x9003 if info.date_taken.is_none() => info.date_taken = t.text(typ, count, field), 0x8769 => { if let Some(p) = t.u32(field) { queue.push(p as usize); @@ -252,6 +259,47 @@ mod tests { /// Hostile payloads: huge counts, offsets near `u32::MAX`, self-referencing IFDs and random /// bytes read as `None` fields instead of panicking or overflowing (usize is 32 bits on wasm). + #[test] + fn duplicate_text_tags_keep_the_first_value_without_copying_large_duplicates() { + let value = vec![b'X'; 2 * 1024 * 1024]; + let mut b = b"II*\0".to_vec(); + b.extend_from_slice(&8u32.to_le_bytes()); + // IFD0: Make="M", a duplicate Make pointing at a 2 MiB payload, and the Exif IFD link. + b.extend_from_slice(&3u16.to_le_bytes()); + b.extend_from_slice(&0x010Fu16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&2u32.to_le_bytes()); + b.extend_from_slice(b"M\0\0\0"); + b.extend_from_slice(&0x010Fu16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&(value.len() as u32).to_le_bytes()); + b.extend_from_slice(&80u32.to_le_bytes()); + b.extend_from_slice(&0x8769u16.to_le_bytes()); + b.extend_from_slice(&4u16.to_le_bytes()); + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&50u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + // Exif IFD at 50: DateTimeOriginal="D" and the same large duplicate payload at offset 80. + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&0x9003u16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&2u32.to_le_bytes()); + b.extend_from_slice(b"D\0\0\0"); + b.extend_from_slice(&0x9003u16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&(value.len() as u32).to_le_bytes()); + b.extend_from_slice(&80u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&value); + + TEXT_DECODES.with(|c| c.set(0)); + let info = read(&b); + assert_eq!(info.make.as_deref(), Some("M")); + assert_eq!(info.date_taken.as_deref(), Some("D")); + // One decode per tag: duplicates must be skipped before `text` copies their payload. + assert_eq!(TEXT_DECODES.with(|c| c.get()), 2); + } + #[test] fn hostile_payloads_never_panic() { let entry = |tag: u16, typ: u16, count: u32, value: u32| { diff --git a/crates/algo/src/histogram.rs b/crates/algo/src/histogram.rs index 4a4fb45e20..849b222c2a 100644 --- a/crates/algo/src/histogram.rs +++ b/crates/algo/src/histogram.rs @@ -28,6 +28,18 @@ impl RgbHistogram { /// Each visible pixel counts once, including partially transparent pixels. Non-finite /// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks. pub fn from_rgba(pixels: &[[f32; 4]]) -> Self { + // Camera Raw counts its whole proxy (~450k pixels) on every slider step, so the count + // runs in blocks across cores (#211, P36). Integer sums don't depend on the order the + // blocks finish in, so the result is the same as one pass over all pixels. + #[cfg(not(target_arch = "wasm32"))] + if pixels.len() >= PARALLEL_PIXELS { + use rayon::prelude::*; + return pixels.par_chunks(PARALLEL_PIXELS / 2).map(Self::count).reduce(Self::default, Self::merge); + } + Self::count(pixels) + } + + fn count(pixels: &[[f32; 4]]) -> Self { let mut h = Self::default(); for pixel in pixels { if !pixel[3].is_finite() { @@ -68,8 +80,29 @@ impl RgbHistogram { } h } + + /// Sum of two block counts. Saturating like the counters themselves. + #[cfg(not(target_arch = "wasm32"))] + fn merge(mut self, other: Self) -> Self { + let add = |a: &mut [u64], b: &[u64]| a.iter_mut().zip(b).for_each(|(a, b)| *a = a.saturating_add(*b)); + for (a, b) in self.channels.iter_mut().zip(&other.channels) { + add(a, b); + } + add(&mut self.underflow, &other.underflow); + add(&mut self.overflow, &other.overflow); + add(&mut self.shadows, &other.shadows); + add(&mut self.highlights, &other.highlights); + self.samples = self.samples.saturating_add(other.samples); + self.transparent = self.transparent.saturating_add(other.transparent); + self.invalid = self.invalid.saturating_add(other.invalid); + self + } } +/// Below this many pixels one pass is faster than splitting the count across cores. +#[cfg(not(target_arch = "wasm32"))] +const PARALLEL_PIXELS: usize = 64 * 1024; + /// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B): /// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins. /// Transparent and non-finite pixels clip nothing. @@ -94,6 +127,30 @@ pub fn clipping(p: [f32; 4]) -> (u8, u8) { mod tests { use super::*; + // The parallel path, and its threshold, exist only off wasm32. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn matches_the_single_pass_on_every_domain_and_across_block_edges() { + // Every special value at every channel, plus a dense ramp through each bin's rounding + // edge; lengths around the parallel threshold and its block size. + let special = [0.0, -0.0, 1.0, -1e-7, 1.0 + 1e-7, 0.5 / 255.0, 254.5 / 255.0, -3.0, 4.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MIN_POSITIVE]; + let mut pool: Vec<[f32; 4]> = Vec::new(); + for (i, &v) in special.iter().enumerate() { + for alpha in [1.0, 0.5, 0.0, -1.0, f32::NAN] { + pool.push([v, special[(i + 3) % special.len()], 0.25, alpha]); + pool.push([0.75, v, special[(i + 5) % special.len()], alpha]); + } + } + pool.extend((0..4096).map(|i| { + let v = i as f32 / 4095.0 * 1.2 - 0.1; + [v, 1.0 - v, (v * 7.0).fract(), 1.0] + })); + for len in [0, 1, PARALLEL_PIXELS / 2 + 1, PARALLEL_PIXELS - 1, PARALLEL_PIXELS, 3 * PARALLEL_PIXELS + 17] { + let pixels: Vec<_> = (0..len).map(|i| pool[(i * 7919) % pool.len()]).collect(); + assert_eq!(RgbHistogram::from_rgba(&pixels), RgbHistogram::count(&pixels), "{len} pixels"); + } + } + #[test] fn exact_bins_and_channel_totals() { let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect(); diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 2dd40198d8..87ed2a3576 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -319,6 +319,10 @@ fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool { /// Bounds of the hole pixels of a role grid. fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option { + // A zero-width grid holds no pixels whatever its height; don't size a row table by it. + if w == 0 || h == 0 { + return None; + } // Per row the first and last hole column; the union of the rows' spans. let rows: Vec> = par::map(h, w * h >= PAR_MIN, |y| { let row = px.get(y * w..(y + 1) * w)?; @@ -2047,6 +2051,14 @@ mod tests { assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6); } + /// A zero-width image has no pixels however tall it is claimed to be: no per-row table. + #[test] + fn zero_width_images_complete_without_sizing_by_height() { + let p = CompleteParams::default(); + assert_eq!(complete(0, 1 << 40, 3, &[], &[], &p), Some(Vec::new())); + assert_eq!(complete(1 << 40, 0, 3, &[], &[], &p), Some(Vec::new())); + } + /// Smooth gradient plus deterministic fine noise. fn texture(w: usize, h: usize) -> Vec { let mut rng = Rng::new(7); diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 35c195f399..9e8fa5301d 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -18,6 +18,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod art_history; mod artistic; mod artistic_fx; mod blur; @@ -86,6 +87,7 @@ pub mod warp; pub mod wideangle; pub use artistic::{GALLERY_CATEGORIES, GalleryEffect, GalleryFilter, GalleryParam, GalleryParamKind}; +pub use distort::MAX_WAVE_GENERATORS; pub use image::{Edge, Image}; pub use params_ext::*; @@ -560,7 +562,7 @@ pub enum FilterParams { impl FilterParams { /// Pixels read outside an output tile. pub fn halo(&self) -> Halo { - let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1); + let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1)); match self { FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius), FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)), @@ -570,9 +572,9 @@ impl FilterParams { | FilterParams::Median { radius } | FilterParams::DustAndScratches { radius, .. } | FilterParams::Minimum { radius, .. } - | FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1), + | FilterParams::Maximum { radius, .. } => Halo::Radius((radius.max(0.0).ceil() as i32).saturating_add(1)), FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2), - FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1), + FilterParams::Mosaic { cell_size } => Halo::Radius((cell_size.max(1.0).ceil() as i32).saturating_add(1)), FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2), FilterParams::FindEdges => Halo::Radius(1), FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0), @@ -697,7 +699,7 @@ impl FilterParams { /// Halos of the second filter batch. fn halo_ext(p: &FilterParams) -> Halo { - let r = |v: f32| Halo::Radius(v.max(0.0).ceil() as i32 + 1); + let r = |v: f32| Halo::Radius((v.max(0.0).ceil() as i32).saturating_add(1)); match p { FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0), FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0), @@ -718,8 +720,9 @@ fn halo_ext(p: &FilterParams) -> Halo { FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0), FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()), FilterParams::ReduceNoise { .. } => r(denoise::reach()), - FilterParams::SmartBlur { radius, .. } => r(*radius), - FilterParams::LensBlur { radius, .. } => r(*radius), + // The kernels clamp the radius to 100 px: size the source window by what they use. + FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)), + FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)), FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)), FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)), FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))), @@ -998,10 +1001,18 @@ pub fn apply_in_with( } /// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the -/// re-read margin (and its cost) below ~2× the tile area. +/// re-read margin (and its cost) below ~2× the tile area. The radius is input-derived, so the +/// halo arithmetic is done in `i64` and the result capped: an `i32::MAX` radius must not +/// overflow in debug builds, and must not produce a tile bigger than the hard cap. fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { match params.halo_for(bounds) { - Halo::Radius(r) => TILE.max((2 * r + 63) / 64 * 64).min(2048), + // The radius is input-derived: widen to i64 before the arithmetic and cap the + // conversion, so an i32::MAX radius cannot overflow (debug panic) and cannot make a + // tile bigger than the hard cap below (131_072 = 2048 * 64, one tile row). + Halo::Radius(r) => { + let halo = ((2 * i64::from(r) + 63) / 64 * 64).min(131_072); + TILE.max(halo as i32).min(2048) + } Halo::Bounds => TILE, } } @@ -1095,14 +1106,16 @@ fn apply_tiled_with_impl( return None; } let halo = params.halo_for(bounds); - if matches!(params, FilterParams::UnsharpMask { .. }) { - let Halo::Radius(reach) = halo else { return None }; + // Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is + // caller-supplied, so a short parameter set can ask for a huge source window per tile. + if let Halo::Radius(reach) = halo { let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize); let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?; let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?; // Reject hostile source-window sizes before Image::read allocates them. // Ordinary filter parameters and automatic tiles fit these bounds. - if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::())? > isize::MAX as usize || tile_count > 1_048_576 { + let too_many_tiles = matches!(params, FilterParams::UnsharpMask { .. }) && tile_count > 1_048_576; + if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::())? > isize::MAX as usize || too_many_tiles { return None; } } @@ -1130,16 +1143,43 @@ fn apply_tiled_with_impl( // Tiles finished so far, for progress reported per tile (from any worker thread). let finished = std::sync::atomic::AtomicUsize::new(0); let total = tiles.len().max(1); - let run = |t: &Rect| -> (Rect, Vec) { + enum TileData { + Samples(Vec), + Encoded(Vec), + } + // Cell filters become writeback-bound once their geometry is reused. Encode + // on the tile workers; committing the group then only copies native bytes. + let finish = |data: Vec| { + if matches!(params, FilterParams::Crystallize { .. } | FilterParams::Pointillize { .. }) + && let Some(bytes) = pixelate::cells::encode(fmt, &data) + { + TileData::Encoded(bytes) + } else { + TileData::Samples(data) + } + }; + let run = |t: &Rect| -> (Rect, TileData) { // Cancelled: skip the remaining tiles of the group (the result is discarded). if ctl.cancelled() { - return (*t, Vec::new()); + return (*t, TileData::Samples(Vec::new())); } let tick = || { let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; // The last few percent are the writes after each group. ctl.progress(0.98 * n as f32 / total as f32); }; + let sparse = match *params { + FilterParams::Crystallize { cell_size, seed } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, None, extent), + FilterParams::Pointillize { cell_size, seed, background } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, Some(background), extent), + _ => None, + }; + if let Some((src, mut data)) = sparse { + if let Some(sel) = selection { + mix_selection(&mut data, *t, sel, &src, fmt.alpha); + } + tick(); + return (*t, finish(data)); + } let owned; let src = match (&shared, halo) { (Some(s), _) => s, @@ -1168,13 +1208,13 @@ fn apply_tiled_with_impl( _ => Some(kernel(params, src, *t, &ctx)), }, }) else { - return (*t, Vec::new()); + return (*t, TileData::Samples(Vec::new())); }; if let Some(sel) = selection { mix_selection(&mut data, *t, sel, src, fmt.alpha); } tick(); - (*t, data) + (*t, finish(data)) }; // Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each // group is written before the next starts, so a huge layer never holds all its results as @@ -1190,24 +1230,33 @@ fn apply_tiled_with_impl( return None; } #[cfg(not(target_arch = "wasm32"))] - let results: Vec<(Rect, Vec)> = { + let results: Vec<(Rect, TileData)> = { use rayon::prelude::*; chunk.par_iter().map(run).collect() }; #[cfg(target_arch = "wasm32")] - let results: Vec<(Rect, Vec)> = chunk.iter().map(run).collect(); + let results: Vec<(Rect, TileData)> = chunk.iter().map(run).collect(); if ctl.cancelled() { return None; } // Checked filters can reject malformed geometry or an allocation failure. // Do not hand an incomplete tile to Surface::write_region. if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. })) - && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) + && results.iter().any(|(t, data)| { + let pixels = t.width() as usize * t.height() as usize; + match data { + TileData::Samples(v) => v.len() != pixels * fmt.channels(), + TileData::Encoded(v) => v.len() != pixels * fmt.bytes_per_pixel(), + } + }) { return None; } for (t, data) in results { - out.write_region(t, &data); + match data { + TileData::Samples(data) => out.write_region(t, &data), + TileData::Encoded(data) => out.write_interleaved(t, &data), + } } } out.prune(); diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index bb6b1d4b1b..5d35f5791b 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -19,6 +19,7 @@ //! copied untouched. [`ProxyImage::render`] is the cheap preview path: bilinear field and //! bilinear source on a downsampled RGBA copy, re-rendered only where a dab changed things. +use crate::par; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; @@ -186,6 +187,13 @@ struct StrokeCursor { carry: f64, } +/// Dabs over this many field nodes compute their rows in parallel (an 800 px brush has about +/// 125,000); smaller ones are not worth the hand-off. +const PARALLEL_DAB_NODES: usize = 16_384; + +/// Bound completed output buffers independently of the document's size. +const RENDER_BATCH_TILES: usize = 64; + /// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a /// drag is many small pushes and its edge stays smooth. fn dab_spacing(r: f64) -> f64 { @@ -204,11 +212,20 @@ pub fn auto_cell(bounds: Rect) -> f64 { if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 } } +/// Most nodes of a field's displacement grid (12 bytes each with the freeze mask): 192 MB. +const MAX_FIELD_NODES: usize = 1 << 24; + impl LiquifyField { pub fn new(bounds: Rect, cell: f64) -> Self { - let cell = cell.clamp(1.0, 64.0); - let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2; - let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2; + let mut cell = cell.clamp(1.0, 64.0); + let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (f64::from(bounds.height()) / cell).ceil() as usize + 2); + let (mut w, mut h) = nodes(cell); + // The extent is the document canvas, which a file declares: past the node budget the + // field gets coarser (it is interpolated anyway) instead of asking for gigabytes. + while w.saturating_mul(h) > MAX_FIELD_NODES { + cell *= 2.0; + (w, h) = nodes(cell); + } LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None } } @@ -487,9 +504,11 @@ impl LiquifyField { bilinear(&self.d, self.w, self.h, gx, gy) } }; - let mut writes: Vec<(usize, [f32; 2], f32)> = Vec::with_capacity((i1 - i0 + 1) * (j1 - j0 + 1)); + // Every node reads only the snapshot and the field as it was before this dab (the writes + // land after), so rows are independent: a large brush computes them in parallel. let inv_cell = 1.0 / self.cell; - for j in j0..=j1 { + let node_row = |j: usize| -> Vec<(usize, [f32; 2], f32)> { + let mut row = Vec::with_capacity(i1 - i0 + 1); for i in i0..=i1 { let p = self.node_pos(i, j); let (dx, dy) = (p[0] - c[0], p[1] - c[1]); @@ -503,7 +522,7 @@ impl LiquifyField { if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw { let f = self.freeze[idx]; let v = if s.tool == LiquifyTool::Freeze { (f as f64 + k_raw).min(1.0) } else { (f as f64 - k_raw).max(0.0) }; - writes.push((idx, self.d[idx], v as f32)); + row.push((idx, self.d[idx], v as f32)); continue; } let k = k_raw * (1.0 - f64::from(self.freeze[idx])); @@ -553,10 +572,13 @@ impl LiquifyField { [(q[0] + dq[0] - p[0]) as f32, (q[1] + dq[1] - p[1]) as f32] } }; - writes.push((idx, new, self.freeze[idx])); + row.push((idx, new, self.freeze[idx])); } - } - for (idx, d, f) in writes { + row + }; + let rows = j1 - j0 + 1; + let writes = par::map(rows, rows * (i1 - i0 + 1) > PARALLEL_DAB_NODES, |row| node_row(j0 + row)); + for (idx, d, f) in writes.into_iter().flatten() { self.d[idx] = d; self.freeze[idx] = f; } @@ -574,14 +596,11 @@ impl LiquifyField { } /// Catmull-Rom (bicubic) displacement at grid coordinates. - fn sample_cubic(&self, gx: f64, gy: f64, wx: &[f64; 4], wy: &[f64; 4]) -> [f64; 2] { - let (ix, iy) = (gx.floor() as isize, gy.floor() as isize); + fn sample_cubic(&self, xs: &[(usize, f64); 4], ys: &[(usize, f64); 4]) -> [f64; 2] { let mut acc = [0.0f64; 2]; - for (jj, wyj) in wy.iter().enumerate() { - let y = (iy - 1 + jj as isize).clamp(0, self.h as isize - 1) as usize; + for &(y, wyj) in ys { let row = &self.d[y * self.w..(y + 1) * self.w]; - for (ii, wxi) in wx.iter().enumerate() { - let x = (ix - 1 + ii as isize).clamp(0, self.w as isize - 1) as usize; + for &(x, wxi) in xs { let k = wxi * wyj; acc[0] += f64::from(row[x][0]) * k; acc[1] += f64::from(row[x][1]) * k; @@ -641,6 +660,13 @@ fn catmull_rom(t: f64) -> [f64; 4] { [-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2] } +/// A field axis's clamped nodes and weights, reusable across a tile's rows or columns. +fn cubic_taps(g: f64, len: usize) -> [(usize, f64); 4] { + let i = g.floor() as isize; + let weights = catmull_rom(g - g.floor()); + std::array::from_fn(|k| ((i - 1 + k as isize).clamp(0, len as isize - 1) as usize, weights[k])) +} + /// Resamples `src` through the field: inside `field.bounds` each pixel shows the source at /// `p + d(p)` (bicubic field, bicubic premultiplied source, edge pixels repeat past the field /// bounds); everything outside the bounds is kept. Same format as `src`. @@ -653,25 +679,32 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { if field.is_identity() || b.is_empty() { return out; } - let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3; + // A 1D Catmull-Rom stencil's absolute weights sum to at most 5/4, so the 2D + // interpolated displacement can reach 25/16 of the largest nodal displacement. + let maxd = ((f64::from(field.max_displacement()) * (25.0 / 16.0)).ceil() as i32).saturating_add(3); let has_freeze = field.freeze.iter().any(|&f| f > 0.0); let tiles: Vec = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect(); let work = |t: &Rect| -> Option<(Rect, Vec)> { let w = t.width() as usize; let h = t.height() as usize; - // Field per pixel (bicubic), rows share their y weights. + // Field per pixel (bicubic), with axis taps shared across rows and columns. let mut disp = vec![[0.0f64; 2]; w * h]; let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); let mut moved = false; + let columns: Vec<_> = (0..w) + .map(|x| { + let px = f64::from(t.x0) + x as f64 + 0.5; + let gx = (px - f64::from(b.x0) - 0.5) / field.cell; + (px, cubic_taps(gx, field.w)) + }) + .collect(); for y in 0..h { let py = f64::from(t.y0) + y as f64 + 0.5; let gy = (py - f64::from(b.y0) - 0.5) / field.cell; - let wy = catmull_rom(gy - gy.floor()); - for x in 0..w { - let px = f64::from(t.x0) + x as f64 + 0.5; - let gx = (px - f64::from(b.x0) - 0.5) / field.cell; - let wx = catmull_rom(gx - gx.floor()); - let mut d = field.sample_cubic(gx, gy, &wx, &wy); + let ys = cubic_taps(gy, field.h); + for (x, (px, xs)) in columns.iter().enumerate() { + let px = *px; + let mut d = field.sample_cubic(xs, &ys); if has_freeze { let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0); if mask > 0.0 { @@ -753,15 +786,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { } Some((*t, outp)) }; - #[cfg(not(target_arch = "wasm32"))] - let done: Vec<(Rect, Vec)> = { - use rayon::prelude::*; - tiles.par_iter().filter_map(work).collect() - }; - #[cfg(target_arch = "wasm32")] - let done: Vec<(Rect, Vec)> = tiles.iter().filter_map(work).collect(); - for (r, v) in done { - out.write_region(r, &v); + for batch in tiles.chunks(RENDER_BATCH_TILES) { + for (r, v) in par::map(batch.len(), true, |i| batch.get(i).and_then(work)).into_iter().flatten() { + out.write_region(r, &v); + } } if alpha { out.prune(); @@ -929,18 +957,23 @@ impl ProxyImage { return false; } source_scratch.resize(source_len, [0.0; 4]); - src.read_rgba_into(src_rect, source_scratch); - if !src.format().alpha { - for pixel in source_scratch.iter_mut() { - pixel[3] = 1.0; + // Read and premultiply the source in one pass per band of rows, the bands in parallel: at + // an 800 px brush it is about a million pixels (P30). + let opaque = !src.format().alpha; + const BAND_ROWS: usize = 32; + par::chunks_mut(source_scratch, sw * BAND_ROWS, true, |band_index, band| { + let y = src_rect.y0 + (band_index * BAND_ROWS) as i32; + src.read_rgba_into(Rect::new(src_rect.x0, y, src_rect.x1, y + (band.len() / sw) as i32), band); + for pixel in band.iter_mut() { + if opaque { + pixel[3] = 1.0; + } + let alpha = pixel[3]; + pixel[0] *= alpha; + pixel[1] *= alpha; + pixel[2] *= alpha; } - } - for pixel in source_scratch.iter_mut() { - let alpha = pixel[3]; - pixel[0] *= alpha; - pixel[1] *= alpha; - pixel[2] *= alpha; - } + }); let x0 = (region.x0 - bounds.x0) as usize; let x1 = (region.x1 - bounds.x0) as usize; @@ -1105,6 +1138,15 @@ mod tests { assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px"); } + #[test] + fn a_huge_canvas_gets_a_coarser_field_not_a_huge_one() { + // 400000² px at 2 px per node is 4·10¹⁰ nodes. + let f = LiquifyField::new(Rect::new(0, 0, 400_000, 400_000), 2.0); + assert!(f.w * f.h <= MAX_FIELD_NODES && f.d.len() == f.w * f.h && f.freeze.len() == f.d.len()); + assert!(f.cell > 64.0); + assert_eq!(LiquifyField::new(Rect::new(0, 0, 100, 100), 2.0).cell, 2.0); + } + #[test] fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() { // A segment that does not start where the last one ended begins a new run of dabs there. @@ -1283,6 +1325,44 @@ mod tests { } } + #[test] + fn a_wide_integer_translation_keeps_channels_and_tile_edges_at_every_format() { + let b = Rect::new(13, -1, 13 + 70 * 256, 1); + let w = b.width() as usize; + for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let format = PixelFormat::new(mode, depth, true); + let n = format.channels(); + let mut data = Vec::new(); + for i in 0..w * 2 { + let alpha = if i % 13 == 0 { 0.0 } else { 0.5 }; + for c in 0..mode.color_channels() { + data.push(if alpha == 0.0 { 0.0 } else { ((i * 19 + c * 43) % 251 + 2) as f32 / 255.0 }); + } + data.push(alpha); + } + let mut source = Surface::new(format); + source.write_region(b, &data); + source.write_pixel(b.x0 - 1, b.y0 - 1, &vec![0.75; n]); + let before = source.read_region(b); + let mut field = LiquifyField::new(b, 2.5); + field.d.fill([-1.0, 0.0]); + let output = apply_liquify(&source, &field); + let after = output.read_region(b); + let tolerance = if depth == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 }; + for (original, moved) in before.chunks_exact(w * n).zip(after.chunks_exact(w * n)) { + for (x, pixel) in moved.chunks_exact(n).enumerate() { + let offset = x.saturating_sub(1) * n; + for (actual, expected) in pixel.iter().zip(&original[offset..offset + n]) { + assert!((actual - expected).abs() <= tolerance, "{mode:?}/{depth:?} at column {x}"); + } + } + } + assert_eq!(output.pixel(b.x0 - 1, b.y0 - 1), source.pixel(b.x0 - 1, b.y0 - 1)); + } + } + } + #[test] fn no_strokes_is_identity_at_every_depth() { for st in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/algo/src/magnetic.rs b/crates/algo/src/magnetic.rs index f2dab08816..54759237cb 100644 --- a/crates/algo/src/magnetic.rs +++ b/crates/algo/src/magnetic.rs @@ -512,12 +512,11 @@ fn direction_cost(tp: [f32; 2], tq: [f32; 2], dx: i32, dy: i32, len: f32) -> f32 if tp == [0.0; 2] || tq == [0.0; 2] { return NEUTRAL_DIRECTION; } - let mut l = [dx as f32 / len, dy as f32 / len]; - if tp[0] * l[0] + tp[1] * l[1] < 0.0 { - l = [-l[0], -l[1]]; - } - let dp = (tp[0] * l[0] + tp[1] * l[1]).clamp(-1.0, 1.0); - let dq = (l[0] * tq[0] + l[1] * tq[1]).clamp(-1.0, 1.0); + // An edge tangent is an axis, not a directed vector: reversing the strongest channel's + // gradient must not make an otherwise straight edge expensive to follow. + let l = [dx as f32 / len, dy as f32 / len]; + let dp = (tp[0] * l[0] + tp[1] * l[1]).abs().clamp(0.0, 1.0); + let dq = (l[0] * tq[0] + l[1] * tq[1]).abs().clamp(0.0, 1.0); 2.0 / (3.0 * PI) * (dp.acos() + dq.acos()) } diff --git a/crates/algo/src/magnetic/tests.rs b/crates/algo/src/magnetic/tests.rs index ccfc73bc04..ee187bc781 100644 --- a/crates/algo/src/magnetic/tests.rs +++ b/crates/algo/src/magnetic/tests.rs @@ -55,6 +55,39 @@ fn snaps_to_the_most_prominent_edge() { assert!((p[0] - 18.0).abs() < 0.01, "{p:?}"); } +#[test] +fn gradient_polarity_does_not_make_a_straight_edge_look_like_a_turn() { + // A gradient can reverse across adjacent pixels when another colour channel becomes the + // strongest one. The edge tangent is an axis, so its sign must not change the path cost. + let forward = direction_cost([1.0, 0.0], [1.0, 0.0], 1, 0, 1.0); + let reversed = direction_cost([1.0, 0.0], [-1.0, 0.0], 1, 0, 1.0); + assert!(forward < 1e-6, "aligned edge: {forward}"); + assert!(reversed < 1e-6, "same edge with reversed gradient polarity: {reversed}"); +} + +#[test] +fn traces_a_straight_edge_through_a_gradient_polarity_change() { + let read = Cell::new(0); + // The contrast stays high, but the edge changes from dark-to-light to light-to-dark halfway + // down. The horizontal seam intersects it; the guide stays on the vertical contour. + let mut f = image( + |x, y| { + let left = x < 32; + let top = y < 32; + let white = left != top; + grey(if white { 255 } else { 0 }) + }, + &read, + ); + let mut t = Tracer::new(Rect::new(0, 0, 64, 64)); + let path = t.trace(&mut f, [32.0, 5.0], [32.0, 59.0], &[[32.0, 32.0]], Settings::default()); + assert_eq!(path.first(), Some(&[32.0, 5.0])); + assert_eq!(path.last(), Some(&[32.0, 59.0])); + for p in &path { + assert!((p[0] - 32.0).abs() < 1.0, "left the high-contrast edge: {p:?}; {path:?}"); + } +} + #[test] fn trace_follows_a_curved_edge() { let read = Cell::new(0); diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index e40b687040..f4bfccb0b5 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec mod brush; mod morphology; -pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area}; +pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_surface, painted_tiles, stroke_area, swept_area}; /// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman: /// three comparisons per sample whatever the radius. @@ -413,6 +413,22 @@ where lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect() } +/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`] +/// (about two bytes a pixel while it is assembled), so callers refuse a larger refine up front +/// rather than allocate it: 2³⁰ pixels, a 32k × 32k selection. +pub const MAX_REFINE_AREA: u64 = 1 << 30; + +/// The area [`refine_mask`] and [`refine_mask_brushed`] compute and allocate their result over: +/// `content` grown by how far `p` can spread it, plus where `brush` adds, within `canvas`. Empty +/// when `content` misses the canvas. +pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>) -> Rect { + let content = content.intersect(&canvas); + if content.is_empty() { + return Rect::EMPTY; + } + content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas) +} + /// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds /// 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. @@ -446,11 +462,10 @@ fn refine_mask_with( where F: Fn(&[f32], usize, usize, usize, bool) -> Vec + Copy + Sync + Send, { - let content = content.intersect(&canvas); - if content.is_empty() { + let bbox = refine_area(content, canvas, p, brush); + if bbox.is_empty() { return None; } - let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas); let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX)); let mut tiles = Vec::new(); let mut y = bbox.y0; diff --git a/crates/algo/src/matting/brush.rs b/crates/algo/src/matting/brush.rs index f70342e6d7..24e4c82ad1 100644 --- a/crates/algo/src/matting/brush.rs +++ b/crates/algo/src/matting/brush.rs @@ -10,10 +10,17 @@ //! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution //! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window. -use photocraft_geom::Rect; +use std::collections::{BTreeMap, BTreeSet}; + +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::Surface; use super::box_mean; use crate::segment::RgbImage; +use crate::selection::{MAX_PAINTED_TILES, clip_segment}; + +const TS: usize = TILE_SIZE as usize; /// One Refine Edge Brush stroke, in document pixels. #[derive(Clone, Debug, PartialEq)] @@ -29,26 +36,42 @@ pub struct EdgeStroke { /// Brush diameters the strokes may use. pub const SIZE_RANGE: std::ops::RangeInclusive = 1.0..=5000.0; -/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`. +/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched), kept only for +/// the tiles the strokes paint, so a thin stroke across a huge canvas costs those tiles, not its +/// bounding box (#8783). #[derive(Clone, Debug, PartialEq)] pub struct EdgeBrush { + /// Where the strokes can paint. bbox: Rect, - /// Coverage × 127, row-major over `bbox`. - v: Vec, + /// Coverage × 127 per painted tile, row-major over the tile. + tiles: BTreeMap>, /// Bounds of the added (positive) coverage. added: Rect, /// Box radius of the colour estimate (pixels). window: usize, } -/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`. -fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 { +/// Distance from pixel centre `p` to the segment from `a` to `b`. +fn segment_distance(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let len2 = dx * dx + dy * dy; let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; let (cx, cy) = (a.0 + t * dx, a.1 + t * dy); - let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt(); - (r + 0.5 - d).clamp(0.0, 1.0) + ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt() +} + +/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`. +fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 { + (r + 0.5 - segment_distance(p, a, b)).clamp(0.0, 1.0) +} + +/// Coverage (0–1) at distance `d` from a brush's path, for radius `r` and a solid core of radius +/// `core` (hardness × `r`): full inside the core, then a smooth fall to zero half a pixel past `r` +/// (a hard brush keeps just the one-pixel anti-aliased rim). +fn soft_coverage(d: f32, r: f32, core: f32) -> f32 { + let ramp = r + 0.5 - core; + let t = ((r + 0.5 - d) / ramp.max(1.0)).clamp(0.0, 1.0); + if ramp > 1.0 { t * t * (3.0 - 2.0 * t) } else { t } } /// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle. @@ -64,27 +87,113 @@ pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64; /// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite. pub fn swept_area(strokes: &[EdgeStroke]) -> f64 { - strokes - .iter() - .map(|s| { - let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end())); - let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum(); - if len.is_finite() { len * size + size * size } else { f64::INFINITY } - }) - .sum() + strokes.iter().map(|s| stroke_area(&s.points, s.size)).sum() } -/// The stroke's path as short segments: points closer than a quarter of the radius to the last -/// kept one are dropped, and long segments are split at most `r` long, so each segment's box -/// stays close to the area it paints. -fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> { - let Some(&first) = points.first() else { return Vec::new() }; +/// The area one stroke of `points` sweeps with a brush `size` wide: length × size + size²; +/// non-finite strokes count as infinite. +pub fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 { + let size = f64::from(size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end())); + let len: f64 = points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum(); + if len.is_finite() { len * size + size * size } else { f64::INFINITY } +} + +/// The tiles of `canvas` a round brush `size` px wide along `points` can paint, in order; `None` +/// when that is more than `limit` (a thin stroke sweeps little but touches a whole tile wherever +/// it passes, so this, not [`MAX_SWEPT_AREA`], bounds what painting it allocates; #8783). +pub fn painted_tiles(points: &[(f32, f32)], size: f32, canvas: Rect, limit: usize) -> Option> { + let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; + let mut set = BTreeSet::new(); + // Consecutive pieces mostly fall in the same tiles. + let mut last: Option = None; + for (a, b) in segments(points, r, canvas) { + let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas); + // The tile coordinates `seg` spans (inclusive). + let span = + Rect::new(seg.x0.div_euclid(TILE_SIZE), seg.y0.div_euclid(TILE_SIZE), (seg.x1 - 1).div_euclid(TILE_SIZE), (seg.y1 - 1).div_euclid(TILE_SIZE)); + if seg.is_empty() || last == Some(span) { + continue; + } + last = Some(span); + for tc in seg.tiles() { + set.insert(tc); + if set.len() > limit { + return None; + } + } + } + Some(set) +} + +/// One stroke's coverage (0–255) per tile of `canvas`: the most `cov(pixel centre, a, b)` gives +/// over the path's pieces, so a stroke never paints past full. Only painted tiles are kept; +/// `None` past `limit` tiles (see [`painted_tiles`]). +fn paint_tiles( + points: &[(f32, f32)], + size: f32, + canvas: Rect, + limit: usize, + cov: impl Fn((f32, f32), (f32, f32), (f32, f32)) -> f32, +) -> Option>> { + let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; + let mut tiles: BTreeMap> = painted_tiles(points, size, canvas, limit)?.into_iter().map(|tc| (tc, vec![0u8; TS * TS])).collect(); + for (a, b) in segments(points, r, canvas) { + let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas); + for tc in seg.tiles() { + let o = tc.rect(); + let Some(buf) = tiles.get_mut(&tc) else { continue }; + let tr = o.intersect(&seg); + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let c = (cov((x as f32 + 0.5, y as f32 + 0.5), a, b).clamp(0.0, 1.0) * 255.0).round() as u8; + if let Some(slot) = buf.get_mut((y - o.y0) as usize * TS + (x - o.x0) as usize) + && c > *slot + { + *slot = c; + } + } + } + } + } + tiles.retain(|_, b| b.iter().any(|v| *v > 0)); + Some(tiles) +} + +/// Select and Mask's Brush: the coverage one stroke of a round brush `size` px wide with +/// `hardness` (0–100) paints along `points`, clipped to `canvas`, as a GRAY8 selection holding +/// only the tiles it paints. Overlapping dabs keep the most coverage any of them gives, so a +/// stroke never paints past full. `None` when it paints nothing, has non-finite points, sweeps +/// more than [`MAX_SWEPT_AREA`] or paints more than [`MAX_PAINTED_TILES`] tiles. +pub fn brush_surface(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option { + if points.is_empty() || !size.is_finite() || !hardness.is_finite() || points.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } + if stroke_area(points, size) > MAX_SWEPT_AREA { + return None; + } + let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; + let core = r * hardness.clamp(0.0, 100.0) / 100.0; + let tiles = paint_tiles(points, size, canvas, MAX_PAINTED_TILES, |p, a, b| soft_coverage(segment_distance(p, a, b), r, core))?; + let mut s = Surface::new(PixelFormat::GRAY8); + for (tc, bytes) in &tiles { + s.write_interleaved(tc.rect(), bytes); + } + (s.tile_count() > 0).then_some(s) +} + +/// The stroke's path as short segments within reach of `clip`: points closer than a quarter of +/// the radius to the last kept one are dropped, each segment is cut to `clip` grown by the brush +/// (what lies beyond can't paint inside it), and long ones are split at most `r` long, so each +/// piece's box stays close to the area it paints. The pieces are made as they are painted, never +/// collected, and only where they can paint: a 1 px stroke zigzagging across 2 million px is over +/// 100 million pieces in all, within [`MAX_SWEPT_AREA`] (the #8783 review). +fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator { let min_step = (r / 4.0).max(0.5); let max_step = r.max(2.0); - let mut kept = vec![first]; - for p in points.iter().skip(1) { - let last = kept.last().copied().unwrap_or(first); - if (p.0 - last.0).hypot(p.1 - last.1) >= min_step { + let reach = clip.inflate((r + 2.0).ceil().clamp(0.0, 1e7) as i32); + let mut kept: Vec<(f32, f32)> = Vec::new(); + for p in points { + if kept.last().is_none_or(|l| (p.0 - l.0).hypot(p.1 - l.1) >= min_step) { kept.push(*p); } } @@ -93,25 +202,22 @@ fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> { { kept.push(end); } - if kept.len() == 1 { - return vec![(first, first)]; - } - let mut out = Vec::new(); - for w in kept.windows(2) { - let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + // A single point is one dab. + let dab = if kept.len() == 1 { kept.first().map(|p| (*p, *p)) } else { None }; + let pairs: Vec<((f32, f32), (f32, f32))> = kept.windows(2).filter_map(|w| clip_segment(*w.first()?, *w.get(1)?, reach)).collect(); + dab.into_iter().chain(pairs.into_iter().flat_map(move |(a, b)| { let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize; - let at = |k: usize| { + let at = move |k: usize| { let t = k as f32 / pieces as f32; (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) }; - out.extend((0..pieces).map(|k| (at(k), at(k + 1)))); - } - out + (0..pieces).map(move |k| (at(k), at(k + 1))) + })) } impl EdgeBrush { - /// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more - /// than [`MAX_SWEPT_AREA`]. + /// Rasterises `strokes` (clipped to `canvas`), tile by tile. `None` when they paint nothing, + /// sweep more than [`MAX_SWEPT_AREA`] or paint more than [`MAX_PAINTED_TILES`] tiles. pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option { let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite()); if swept_area(strokes) > MAX_SWEPT_AREA { @@ -126,61 +232,43 @@ impl EdgeBrush { if bbox.is_empty() { return None; } - let bw = bbox.width() as usize; - let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?]; - let mut cov: Vec = Vec::new(); + let mut tiles: BTreeMap> = BTreeMap::new(); let mut max_add = 0.0f32; for s in strokes { - let sb = stroke_bounds(s); - if sb.is_empty() { - continue; - } let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; if s.add { max_add = max_add.max(2.0 * r); } - let sw = sb.width() as usize; - cov.clear(); - cov.resize(sw * sb.height() as usize, 0.0); // The stroke's coverage: the most any of its segments gives (a single dab for one point). - for (a, b) in segments(&s.points, r) { - let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb); - for y in seg.y0..seg.y1 { - for x in seg.x0..seg.x1 { - let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r); - if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize) - && c > *slot - { - *slot = c; - } - } - } - } + let cov = paint_tiles(&s.points, s.size, canvas, MAX_PAINTED_TILES, |p, a, b| capsule(p, a, b, r))?; let target = if s.add { 1.0 } else { -1.0 }; - for y in sb.y0..sb.y1 { - for x in sb.x0..sb.x1 { - let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0); - if c <= 0.0 { - continue; - } - if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) { - let cur = f32::from(*slot) / 127.0; + for (tc, c) in cov { + let v = tiles.entry(tc).or_insert_with(|| vec![0i8; TS * TS]); + for (slot, c) in v.iter_mut().zip(c) { + if c > 0 { + let (cur, c) = (f32::from(*slot) / 127.0, f32::from(c) / 255.0); *slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8; } } } + if tiles.len() > MAX_PAINTED_TILES { + return None; + } } - if v.iter().all(|c| *c == 0) { + tiles.retain(|_, v| v.iter().any(|c| *c != 0)); + if tiles.is_empty() { return None; } - let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); - for (row, line) in v.chunks_exact(bw).enumerate() { - let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue }; - let y = bbox.y0 + row as i32; - (x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1)); + let mut added = Rect::EMPTY; + for (tc, v) in &tiles { + let o = tc.rect(); + for (row, line) in v.as_chunks::().0.iter().enumerate() { + let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue }; + let y = o.y0 + row as i32; + added = added.union(&Rect::new(o.x0 + first as i32, y, o.x0 + last as i32 + 1, y + 1)); + } } - let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY }; - Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize }) + Some(EdgeBrush { bbox, tiles, added, window: max_add.ceil().clamp(4.0, 64.0) as usize }) } /// Where the strokes paint (added or restored). @@ -200,16 +288,32 @@ impl EdgeBrush { /// Signed coverage at document pixel `(x, y)` (zero outside the painted area). pub fn at(&self, x: i32, y: i32) -> f32 { - if !self.bbox.contains(x, y) { - return 0.0; - } - let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize; - self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0) + let (tc, o) = (TileCoord::containing(x, y), (y.rem_euclid(TILE_SIZE) as usize) * TS + x.rem_euclid(TILE_SIZE) as usize); + self.tiles.get(&tc).and_then(|v| v.get(o)).map_or(0.0, |c| f32::from(*c) / 127.0) } /// Signed coverage over `r` (row-major). pub fn read(&self, r: Rect) -> Vec { - (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect() + let w = r.width() as usize; + let mut out = vec![0.0f32; w * r.height() as usize]; + for tc in r.tiles() { + let Some(v) = self.tiles.get(&tc) else { continue }; + let (o, tr) = (tc.rect(), tc.rect().intersect(&r)); + for y in tr.y0..tr.y1 { + let src = (y - o.y0) as usize * TS; + let dst = (y - r.y0) as usize * w; + let (Some(src), Some(dst)) = ( + v.get(src + (tr.x0 - o.x0) as usize..src + (tr.x1 - o.x0) as usize), + out.get_mut(dst + (tr.x0 - r.x0) as usize..dst + (tr.x1 - r.x0) as usize), + ) else { + continue; + }; + for (d, c) in dst.iter_mut().zip(src) { + *d = f32::from(*c) / 127.0; + } + } + } + out } } @@ -276,6 +380,10 @@ mod tests { use crate::segment::ImageSampler; use crate::selection::Region; + fn at(s: &Surface, x: i32, y: i32) -> f32 { + s.pixel(x, y).first().copied().unwrap_or(0.0) + } + /// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's /// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was. #[test] @@ -330,4 +438,73 @@ mod tests { let empty = EdgeStroke { points: vec![], size: 10.0, add: true }; assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none()); } + #[test] + fn brush_surface_paints_hard_and_soft_strokes() { + let canvas = Rect::new(0, 0, 100, 50); + let hard = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted"); + assert!(at(&hard, 50, 25) > 0.99 && at(&hard, 50, 33) > 0.99, "solid inside the radius"); + assert!(at(&hard, 50, 36) == 0.0 && at(&hard, 50, 10) == 0.0, "nothing past it"); + // A soft brush fades from the centre line to the rim. + let soft = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted"); + let (centre, mid, rim) = (at(&soft, 50, 25), at(&soft, 50, 30), at(&soft, 50, 34)); + assert!(centre > 0.95 && mid > 0.2 && mid < 0.8 && rim < 0.1, "{centre} {mid} {rim}"); + // A single point is one dab; clipped to the canvas. + let dab = brush_surface(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted"); + let b = dab.content_bounds(); + assert_eq!((b.x0, b.y0), (0, 0)); + assert!(at(&dab, 1, 1) > 0.99); + } + + #[test] + fn brush_surface_paints_nothing_for_hostile_strokes() { + let canvas = Rect::new(0, 0, 100, 50); + assert!(brush_surface(&[], 10.0, 100.0, canvas).is_none()); + assert!(brush_surface(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none()); + assert!(brush_surface(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none()); + assert!(brush_surface(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none()); + assert!(brush_surface(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas"); + assert!(brush_surface(&[(0.0, 0.0), (1e7, 1e7)], 5000.0, 100.0, canvas).is_none(), "sweeps too much"); + // Sizes and hardness outside their ranges are clamped, not rejected. + assert!(brush_surface(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some()); + } + /// A 1 px stroke zigzagging across 2 million px stays within [`MAX_SWEPT_AREA`] yet splits + /// into over 100 million pieces: painting it must not collect them all first (it allocated + /// about 2 GB up front; the #8783 review). + #[test] + fn a_long_thin_zigzag_paints_without_collecting_its_pieces() { + let canvas = Rect::new(0, 0, 64, 64); + let points: Vec<(f32, f32)> = (0..135).map(|i| (if i % 2 == 0 { 0.0 } else { 2_000_000.0 }, 32.0 + (i % 3) as f32)).collect(); + assert!(stroke_area(&points, 1.0) <= MAX_SWEPT_AREA); + // Only the pieces within reach of the canvas are made. + assert!(segments(&points, 0.5, canvas).count() < 134 * 40, "{}", segments(&points, 0.5, canvas).count()); + let r = brush_surface(&points, 1.0, 100.0, canvas).expect("it paints its row"); + assert!(at(&r, 10, 32) > 0.5); + let strokes = [EdgeStroke { points, size: 1.0, add: true }]; + assert!(EdgeBrush::new(&strokes, canvas).is_some_and(|b| b.at(10, 32) > 0.5)); + } + + /// A 1 px diagonal across a 100,000 px canvas sweeps about 141,000 px, well within + /// [`MAX_SWEPT_AREA`], but its bounding box is 10 billion pixels: painting it must cost the + /// tiles it crosses, not that box (it allocated 40 GB; the #8783 review). + #[test] + fn a_thin_diagonal_across_a_huge_canvas_paints_only_its_tiles() { + let canvas = Rect::new(0, 0, 100_000, 100_000); + let diagonal = [(0.0, 0.0), (99_999.0, 99_999.0)]; + assert!(stroke_area(&diagonal, 1.0) <= MAX_SWEPT_AREA); + let tiles = painted_tiles(&diagonal, 1.0, canvas, MAX_PAINTED_TILES).expect("within the limit"); + assert!(tiles.len() < 1500, "{}", tiles.len()); + let s = brush_surface(&diagonal, 1.0, 100.0, canvas).expect("painted"); + assert!(s.tile_count() <= tiles.len()); + assert!(at(&s, 50_000, 50_000) > 0.5 && at(&s, 50_000, 40_000) == 0.0); + let strokes = [EdgeStroke { points: diagonal.to_vec(), size: 1.0, add: true }]; + let b = EdgeBrush::new(&strokes, canvas).expect("painted"); + assert!(b.at(50_000, 50_000) > 0.5 && b.at(50_000, 40_000) == 0.0); + assert!(b.read(Rect::new(49_990, 49_990, 50_010, 50_010)).iter().any(|v| *v > 0.5)); + // A zigzag crossing more tiles than the limit is refused rather than allocated. + let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect(); + assert!(stroke_area(&zigzag, 1.0) <= MAX_SWEPT_AREA); + assert!(painted_tiles(&zigzag, 1.0, canvas, MAX_PAINTED_TILES).is_none()); + assert!(brush_surface(&zigzag, 1.0, 100.0, canvas).is_none()); + assert!(EdgeBrush::new(&[EdgeStroke { points: zigzag, size: 1.0, add: true }], canvas).is_none()); + } } diff --git a/crates/algo/src/oil.rs b/crates/algo/src/oil.rs index 5a40b056a6..a894efc2ca 100644 --- a/crates/algo/src/oil.rs +++ b/crates/algo/src/oil.rs @@ -29,6 +29,24 @@ pub(crate) struct OilSpec { fn stroke_len(stylization: f32) -> f32 { 1.5 + stylization.clamp(0.1, 10.0) * 2.0 } + +/// `f32::round` (half away from zero) as an integer, without the `roundf` call `f32::round` +/// makes on the baseline x86-64 target. The sum is taken in `f64`, where `v ± 0.5` is exact for +/// every `f32` the cast does not saturate (an `f32` sum is not: `0.49999997 + 0.5` rounds up to +/// `1.0`), so the result equals `v.round() as i32` for every `f32`, NaN (0) and the saturating +/// ends included. +#[inline] +fn round_i32(v: f32) -> i32 { + let v = f64::from(v); + (v + 0.5f64.copysign(v)) as i32 +} + +/// [`round_i32`] clamped into `0..n`. +#[inline] +fn round_index(v: f32, n: usize) -> usize { + round_i32(v).clamp(0, n as i32 - 1) as usize +} + fn tensor_sigma(scale: f32) -> f32 { 0.8 + scale.clamp(0.1, 10.0) * 0.6 } @@ -93,7 +111,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve let sample = |x: f32, y: f32, acc: &mut [f32], k: f32| { let x = x.clamp(0.0, (ww - 1) as f32); let y = y.clamp(0.0, (wh - 1) as f32); - let (x0, y0) = (x.floor() as usize, y.floor() as usize); + let (x0, y0) = (x as usize, y as usize); let (x1, y1) = ((x0 + 1).min(ww - 1), (y0 + 1).min(wh - 1)); let (ax, ay) = (x - x0 as f32, y - y0 as f32); let w00 = (1.0 - ax) * (1.0 - ay) * k; @@ -119,7 +137,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve let (mut qx, mut qy) = (dx, dy); let (mut px, mut py) = (0.0f32, 0.0f32); for k in 1..=steps { - let ti = (qy.round().clamp(0.0, (wh - 1) as f32) as usize) * ww + qx.round().clamp(0.0, (ww - 1) as f32) as usize; + let ti = round_index(qy, wh) * ww + round_index(qx, ww); let (mut tx, mut ty) = tangent[ti]; if k == 1 { tx *= dir; @@ -135,7 +153,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve let wk = 1.0 - k as f32 / (steps + 1) as f32; sample(qx, qy, &mut acc[..n], wk); wsum += wk; - nsum += wk * hash01(win.x0 + qx.round() as i32, win.y0 + qy.round() as i32, 71, 0); + nsum += wk * hash01(win.x0 + round_i32(qx), win.y0 + round_i32(qy), 71, 0); } } let o = (ry * rw + rx) * n; @@ -197,3 +215,59 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve } res } + +#[cfg(test)] +mod tests { + use super::{round_i32, round_index}; + + /// The LIC looks up tangents and bristle noise by rounded sample position; the replacements + /// for `f32::round` must agree with it over every position a tile can produce, and agree with + /// `f32::clamp` at the ends, including non-finite input. + #[test] + fn rounding_matches_the_standard_library() { + let mut v = -40.0f32; + while v <= 2100.0 { + assert_eq!(round_i32(v), v.round() as i32, "round_i32({v})"); + for n in [1usize, 2, 33, 256, 2048] { + let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize; + assert_eq!(round_index(v, n), want, "round_index({v}, {n})"); + } + v += 0.25; + } + // The binade crossings where an `f32` sum `v + 0.5` rounds up (0.5 - 2^-25), the ties, and + // the largest non-integer `f32`. + let below_half = f32::from_bits(0x3eff_ffff); + for v in [below_half, -below_half, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 8_388_607.5, -8_388_607.5, 8_388_608.0, 3.0e9, -3.0e9] { + assert_eq!(round_i32(v), v.round() as i32, "round_i32({v:e})"); + for n in [1usize, 2, 33] { + let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize; + assert_eq!(round_index(v, n), want, "round_index({v:e}, {n})"); + } + } + assert_eq!(round_i32(f32::NAN), f32::NAN.round() as i32); + for n in [1usize, 8] { + assert_eq!(round_index(f32::NEG_INFINITY, n), (f32::NEG_INFINITY.clamp(0.0, (n - 1) as f32)) as usize); + assert_eq!(round_index(f32::INFINITY, n), ((n - 1) as f32) as usize); + } + } + + /// Every `f32` bit pattern in [-4096, 4096] (both signs, about 2.3e9 values, split across + /// threads) rounds exactly like `f32::round`. + #[test] + fn rounding_matches_the_standard_library_for_every_f32_up_to_4096() { + use rayon::prelude::*; + let last = 4096.0f32.to_bits(); + let bad = (0..=last >> 20) + .into_par_iter() + .filter_map(|hi| { + let lo = hi << 20; + let hi_end = (lo | 0xf_ffff).min(last); + (lo..=hi_end).find_map(|bits| { + let v = f32::from_bits(bits); + [v, -v].into_iter().find(|v| round_i32(*v) != v.round() as i32) + }) + }) + .min_by(|a, b| a.abs().total_cmp(&b.abs())); + assert_eq!(bad, None, "round_i32 differs from f32::round"); + } +} diff --git a/crates/algo/src/other2.rs b/crates/algo/src/other2.rs index 61ccfc0038..aa76fb814a 100644 --- a/crates/algo/src/other2.rs +++ b/crates/algo/src/other2.rs @@ -124,6 +124,179 @@ pub(crate) fn hsb_hsl(src: &Image, out: Rect, ctx: &Ctx, input: HsbModel, output #[cfg(test)] mod tests { use super::*; + use photocraft_color::{PixelFormat, SampleType}; + use photocraft_raster::Surface; + + const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + + fn custom_fixture(depth: SampleType, mode: ColorMode, alpha: bool) -> (Surface, Rect) { + let b = Rect::new(-2, -1, 3, 4); + let format = PixelFormat::new(mode, depth, alpha); + let mut src = Surface::new(format); + let mut pixels = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + for c in 0..format.channels() { + pixels.push(((x - b.x0) + 5 * (y - b.y0) + 3 * c as i32) as f32 / 40.0); + } + } + } + src.write_region(b, &pixels); + (src, b) + } + + fn assert_sample(actual: f32, expected: f32, depth: SampleType) { + let (expected, tolerance) = match depth { + SampleType::U8 => (expected.clamp(0.0, 1.0), 1.0 / 255.0), + SampleType::U16 => (expected.clamp(0.0, 1.0), 1.0 / 65535.0), + SampleType::F32 => (expected, 1e-6), + }; + assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} != {expected}"); + } + + fn run_custom(src: &Surface, b: Rect, kernel: Vec, scale: f32, offset: f32) -> Surface { + crate::apply_in(src, &crate::FilterParams::Custom { kernel, scale, offset }, b, b, None, b) + } + + #[test] + fn custom_all_25_taps_use_row_major_offsets_and_repeat_canvas_edges() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + for tap in 0..25 { + let mut kernel = vec![0.0; 25]; + kernel[tap] = 1.0; + let out = run_custom(&src, b, kernel, 1.0, 0.0); + let (dx, dy) = (tap as i32 % 5 - 2, tap as i32 / 5 - 2); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let expected = src.pixel((x + dx).clamp(b.x0, b.x1 - 1), (y + dy).clamp(b.y0, b.y1 - 1)); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], expected[c], depth); + } + assert_eq!(actual[3], src.pixel(x, y)[3], "tap={tap}, alpha must not shift"); + } + } + } + } + } + + #[test] + fn custom_short_kernel_zero_pads_and_ignores_entries_after_25() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + let mut short = vec![0.0; 13]; + short[0] = 0.25; + short[12] = 0.75; + let mut padded = short.clone(); + padded.resize(25, 0.0); + let mut long = padded.clone(); + long.extend([1e6, -1e6, f32::NAN]); + let out = run_custom(&src, b, short, 1.0, 0.0); + assert_eq!(out.read_region(b), run_custom(&src, b, padded, 1.0, 0.0).read_region(b)); + assert_eq!(out.read_region(b), run_custom(&src, b, long, 1.0, 0.0).read_region(b)); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let corner = src.pixel((x - 2).max(b.x0), (y - 2).max(b.y0)); + let centre = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], 0.25 * corner[c] + 0.75 * centre[c], depth); + } + } + } + } + } + + #[test] + fn custom_scale_zero_is_one_and_offset_is_in_255ths() { + for depth in DEPTHS { + let (src, b) = custom_fixture(depth, ColorMode::Rgb, true); + let before = src.read_region(b); + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel.clone(), 0.0, -51.0); + assert_eq!(out.read_region(b), run_custom(&src, b, kernel, 1.0, -51.0).read_region(b)); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let original = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..3 { + assert_sample(actual[c], 2.0 * original[c] - 0.2, depth); + } + assert_eq!(actual[3], original[3]); + } + } + assert_eq!(src.read_region(b), before); + } + } + + #[test] + fn custom_neighbourhood_convolution_preserves_each_pixels_alpha() { + for depth in DEPTHS { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let (src, b) = custom_fixture(depth, mode, true); + let out = run_custom(&src, b, vec![1.0; 25], 25.0, 0.0); + let alpha = src.channels() - 1; + assert_ne!(out.pixel(b.x0, b.y0)[0], src.pixel(b.x0, b.y0)[0], "fixture must change colour"); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + assert_eq!(out.pixel(x, y)[alpha], src.pixel(x, y)[alpha], "{depth:?}, {mode:?}, {x},{y}"); + } + } + } + } + } + + #[test] + fn custom_cmyk_convolves_brightness_including_black_with_or_without_alpha() { + for depth in DEPTHS { + for alpha in [false, true] { + let (src, b) = custom_fixture(depth, ColorMode::Cmyk, alpha); + for offset in [-51.0, 51.0] { + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel, 1.0, offset); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let original = src.pixel(x, y); + let actual = out.pixel(x, y); + for c in 0..4 { + // Twice the reflected brightness, plus offset, then back to ink. + assert_sample(actual[c], 2.0 * original[c] - 1.0 - offset / 255.0, depth); + } + if alpha { + assert_eq!(actual[4], original[4]); + } + } + } + } + } + } + } + + #[test] + fn custom_float_retains_hdr_while_integer_storage_clamps() { + let b = Rect::new(0, 0, 1, 1); + for depth in DEPTHS { + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + src.fill_rect(b, &[0.0, 0.5, 1.0, 0.4]); + let mut kernel = vec![0.0; 25]; + kernel[12] = 2.0; + let out = run_custom(&src, b, kernel, 1.0, -51.0); + let actual = out.pixel(0, 0); + let original = src.pixel(0, 0); + for c in 0..3 { + assert_sample(actual[c], 2.0 * original[c] - 0.2, depth); + } + assert_eq!(actual[3], original[3]); + if depth == SampleType::F32 { + assert!(actual[0] < 0.0 && actual[2] > 1.0); + } else { + assert_eq!((actual[0], actual[2]), (0.0, 1.0)); + } + } + } #[test] fn hsb_hsl_round_trip() { diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index 8146af2582..4862304786 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -5,6 +5,14 @@ use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into}; use serde::{Deserialize, Serialize}; +/// How a fill lays down its colour: blended with a mode, or Clear, which erases what it covers +/// (the Paint Bucket's Mode › Clear). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FillMode { + Blend(BlendMode), + Clear, +} + /// Gradient tool shape. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -59,7 +67,7 @@ pub fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] { fn composite_area( s: &mut Surface, area: Rect, - blend_mode: BlendMode, + mode: FillMode, coverage: impl Fn(i32, i32) -> f32 + Sync, color: impl Fn(i32, i32) -> [f32; 4] + Sync, lock_transparency: bool, @@ -89,12 +97,18 @@ fn composite_area( let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; let px = &mut region[i..i + n]; let d = photocraft_raster::to_rgba(&fmt, px); - let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) }; + let o = match mode { + FillMode::Blend(m) if lock_transparency => blend::composite_atop(m, d, color(x, y), k), + FillMode::Blend(m) => blend::composite(m, d, color(x, y), k), + // Locked transparency keeps the alpha, so Clear leaves the pixel as it is. + FillMode::Clear if lock_transparency => d, + FillMode::Clear => [d[0], d[1], d[2], d[3] * (1.0 - k.min(1.0))], + }; from_rgba_into(&fmt, o, &mut enc); // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. if let Some(i) = inks && fmt.mode == photocraft_color::ColorMode::Cmyk - && blend_mode == BlendMode::Normal + && mode == FillMode::Blend(BlendMode::Normal) && k >= 1.0 { enc[..4].copy_from_slice(&i); @@ -135,7 +149,7 @@ pub fn paint_gradient( composite_area( s, area, - blend_mode, + FillMode::Blend(blend_mode), |x, y| opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), |x, y| { let t = tool_gradient_t(shape, from, to, x as f32 + 0.5, y as f32 + 0.5); @@ -187,11 +201,12 @@ pub fn bucket_fill_src( lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, ) -> bool { - bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None) + bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal)) } /// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target -/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK). +/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK), laid down with `mode` +/// (the options bar's Mode). #[allow(clippy::too_many_arguments)] pub fn bucket_fill_inks( s: &mut Surface, @@ -205,6 +220,7 @@ pub fn bucket_fill_inks( lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, inks: Option<[f32; 4]>, + mode: FillMode, ) -> bool { if !area.contains(seed.0, seed.1) { return false; @@ -212,7 +228,7 @@ pub fn bucket_fill_inks( let img = crate::selection::rgba8_image(s, area); let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false }; drop(img); - bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks); + bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks, mode); true } @@ -226,10 +242,11 @@ pub fn bucket_fill_region_src( lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, ) { - bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None); + bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal)); } -/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`]. +/// [`bucket_fill_region_src`] with the CMYK `inks` and the `mode` of [`bucket_fill_inks`]. +#[allow(clippy::too_many_arguments)] pub fn bucket_fill_region_inks( s: &mut Surface, region: &crate::selection::Region, @@ -238,18 +255,11 @@ pub fn bucket_fill_region_inks( lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, inks: Option<[f32; 4]>, + mode: FillMode, ) { // Composite only over the filled region's box. let b = region.bbox; - composite_area( - s, - b, - BlendMode::Normal, - |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), - src, - lock_transparency, - inks, - ); + composite_area(s, b, mode, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency, inks); } #[cfg(test)] @@ -333,4 +343,24 @@ mod tests { assert_eq!(s.rgba(-1, 4), [0.0; 4], "outside the selection"); assert_eq!(s.rgba(-4, 4), [0.0; 4], "outside the region"); } + + /// The Paint Bucket's Mode: Multiply darkens what's there, Clear erases it (half at 50 %). + #[test] + fn bucket_mode_blends_or_clears() { + let a = Rect::new(0, 0, 4, 1); + let grey = [0.5, 0.5, 0.5, 1.0]; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut s = Surface::new(fmt); + s.fill_rect(a, &grey); + let red = |_, _| [1.0, 0.0, 0.0, 1.0]; + assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, false, red, None, FillMode::Blend(BlendMode::Multiply))); + let px = s.rgba(1, 0); + assert!((px[0] - 0.5).abs() < 0.01 && px[1] < 0.01 && (px[3] - 1.0).abs() < 1e-3, "{fmt:?} multiply: {px:?}"); + assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 0.5, None, false, red, None, FillMode::Clear)); + assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} clear at 50 %"); + // Locked transparency keeps the alpha, so Clear does nothing. + assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, true, red, None, FillMode::Clear)); + assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} locked"); + } + } } diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index 75fa9a4bfb..87f8415b99 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -897,14 +897,39 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o label[i] = img as i32; } } - if no == 0 || nn == no { + if no == 0 { continue; } - let (ox, oy) = (sx / no as f64, sy / no as f64); let (cx, cy) = (nx / nn as f64, ny / nn as f64); + let contained = nn == no; + let (ox, oy) = if contained { + // Wholly inside the composite (#5745: two photos in one document, the second moved + // by Auto-Align so the canvas crops it to the part over the first). Its side of the + // seam is the one it lies towards from the composite's centre; an image centred on + // the composite has no side, and the earlier images keep it. + let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize); + for (i, l) in label.iter().enumerate() { + if *l >= 0 { + px += (i % w) as f64; + py += (i / w) as f64; + pn += 1; + } + } + let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64); + if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 { + continue; + } + (px, py) + } else { + (sx / no as f64, sy / no as f64) + }; let vertical = (cx - ox).abs() >= (cy - oy).abs(); // Image on the increasing side along the seam's cross axis? let positive = if vertical { cx > ox } else { cy > oy }; + // A contained image has no pixels of its own, so a seam on its outer edge would leave it + // nothing: break cost ties away from its side (towards larger k when it lies on the + // decreasing side) so it keeps its side of the overlap. + let prefer_high = contained && !positive; let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() }; let (lines, len) = if vertical { (h, w) } else { (w, h) }; let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line }; @@ -925,7 +950,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o let mut best = (0.0f32, usize::MAX); if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) { best = (f32::INFINITY, usize::MAX); - for dk in [-1i64, 0, 1] { + let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] }; + for dk in dks { let kk = k as i64 + dk; if kk < 0 || kk >= len as i64 { continue; @@ -952,7 +978,12 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o if seam[line] != usize::MAX { continue; } - let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue }; + let Some(k) = (0..len) + .filter(|&k| overlap[idx(line, k)]) + .min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) })) + else { + continue; + }; let (mut l, mut k) = (line, k); loop { seam[l] = k; @@ -972,9 +1003,13 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec], cover: &[Vec], o if s == usize::MAX { continue; } + let on_side = |k: usize| if positive { k > s } else { k < s }; + // A contained image has no other cells, so where the overlap has nothing on its side + // of the seam (a line one cell wide) it takes the seam cell rather than nothing. + let take_seam = contained && !(0..len).any(|k| overlap[idx(line, k)] && on_side(k)); for k in 0..len { let i = idx(line, k); - if overlap[i] && ((positive && k > s) || (!positive && k < s)) { + if overlap[i] && (on_side(k) || (take_seam && k == s)) { label[i] = img as i32; } } @@ -1585,6 +1620,58 @@ mod tests { assert!(max_jump < 0.1, "max step {max_jump}"); assert!(cov.iter().all(|c| *c == 1.0)); } + + #[test] + fn an_image_wholly_inside_the_composite_still_gets_its_side() { + // #5745: the first image covers the whole 120×40 grid, the second only its right part + // (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything. + let (w, h) = (120usize, 40usize); + let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]]; + let cover: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()]; + let labels = seam_labels(w, h, &luma, &cover, &[0, 1]); + for y in 0..h { + assert_eq!(labels[y * w + 5], 0, "row {y}"); + assert_eq!(labels[y * w + w - 1], 1, "row {y}"); + // The second image's region is one run at the right of its seam. + let row = &labels[y * w..(y + 1) * w]; + let first = row.iter().position(|l| *l == 1).unwrap(); + assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}"); + } + // The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap + // instead of on the image's outer edge, which used to leave it nothing. + let left: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()]; + let labels = seam_labels(w, h, &luma, &left, &[0, 1]); + for y in 0..h { + let row = &labels[y * w..(y + 1) * w]; + assert_eq!(row[0], 1, "row {y}: {row:?}"); + assert_eq!(row[w - 1], 0, "row {y}"); + let last = row.iter().rposition(|l| *l == 1).unwrap(); + assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}"); + } + let (tw, th) = (40usize, 120usize); + let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]]; + let top: Vec> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()]; + let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]); + for x in 0..tw { + assert_eq!(labels[x], 1, "column {x}"); + assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}"); + } + // One column or row wide: the seam can only run through it, and the image keeps it. + for col in [0, w - 1] { + let strip: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| i % w == col).collect()]; + let labels = seam_labels(w, h, &luma, &strip, &[0, 1]); + assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i % w == col)), "column {col}: {labels:?}"); + } + let strip: Vec> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw == 0).collect()]; + let labels = seam_labels(tw, th, &tluma, &strip, &[0, 1]); + assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i / tw == 0)), "{labels:?}"); + // An image centred on the composite has no side: the first image keeps the overlap. + let centred: Vec> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()]; + assert!(seam_labels(w, h, &luma, ¢red, &[0, 1]).iter().all(|l| *l == 0)); + // Two images covering the same cells, likewise. + let same = vec![vec![true; w * h]; 2]; + assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0)); + } } /// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they diff --git a/crates/algo/src/pixelate.rs b/crates/algo/src/pixelate.rs index df6edb5fff..12eb232bb2 100644 --- a/crates/algo/src/pixelate.rs +++ b/crates/algo/src/pixelate.rs @@ -5,6 +5,7 @@ use photocraft_geom::Rect; +pub(crate) mod cells; mod facet; #[cfg(test)] mod reference; @@ -120,7 +121,7 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32, /// being distressed) keeps hard edges and its weight instead of greying where cells straddle /// an edge. A cell whose point falls outside the image takes the (premultiplied) average /// colour of its pixels. -pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec { +fn crystallize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec { let n = src.ch; let cell = cell_size.max(2.0); let b = ctx.bounds; @@ -320,7 +321,7 @@ pub(crate) fn mezzotint(src: &Image, out: Rect, ctx: &Ctx, kind: MezzotintType, /// Pointillize: randomly sized dots, coloured from the image at their centre, /// on a canvas of the background colour. -pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec { +fn pointillize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec { let n = src.ch; let cell = cell_size.max(2.0); let b = ctx.bounds; @@ -374,3 +375,11 @@ pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see } res } + +pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec { + cells::crystallize(src, out, ctx, cell_size, seed).unwrap_or_else(|| crystallize_direct(src, out, ctx, cell_size, seed)) +} + +pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec { + cells::pointillize(src, out, ctx, cell_size, seed, background).unwrap_or_else(|| pointillize_direct(src, out, ctx, cell_size, seed, background)) +} diff --git a/crates/algo/src/pixelate/cells.rs b/crates/algo/src/pixelate/cells.rs new file mode 100644 index 0000000000..ebf326fbde --- /dev/null +++ b/crates/algo/src/pixelate/cells.rs @@ -0,0 +1,571 @@ +//! Reuse the jittered lattice in cell space. Raster work stays in the original +//! row/candidate order, including f32 ties and boundary-cell average additions. +use super::*; + +#[derive(Clone, Copy)] +struct Site { + point: (f32, f32), + color: [f32; MAXC], + radius: f32, + sampled: bool, +} + +struct Grid { + x0: i32, + y0: i32, + width: usize, + sites: Vec, +} + +fn key(p: i32, origin: f32, cell: f32) -> i32 { + ((p as f32 + 0.5 - origin) / cell).floor() as i32 +} + +impl Grid { + fn new(src: &Image, area: Rect, ctx: &Ctx, cell: f32, seed: u32, dots: bool) -> Option { + if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(src.ch)? { + return None; + } + Self::new_with(area, ctx, src.ch, cell, seed, dots, |sx, sy, color| { + for (c, value) in color.iter_mut().enumerate() { + *value = src.get(sx, sy, c); + } + src.rect.contains(sx, sy) && ctx.bounds.contains(sx, sy) + }) + } + + #[allow(clippy::too_many_arguments)] + fn new_with(area: Rect, ctx: &Ctx, n: usize, cell: f32, seed: u32, dots: bool, sample: impl Fn(i32, i32, &mut [f32]) -> bool) -> Option { + if area.is_empty() || !cell.is_finite() || cell < 2.0 || n == 0 || n > MAXC { + return None; + } + let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32); + let x0 = key(area.x0, origin.0, cell).checked_sub(1)?; + let y0 = key(area.y0, origin.1, cell).checked_sub(1)?; + let x1 = key(area.x1.checked_sub(1)?, origin.0, cell).checked_add(1)?; + let y1 = key(area.y1.checked_sub(1)?, origin.1, cell).checked_add(1)?; + let width = usize::try_from(x1.checked_sub(x0)?.checked_add(1)?).ok()?; + let height = usize::try_from(y1.checked_sub(y0)?.checked_add(1)?).ok()?; + let len = width.checked_mul(height)?; + // Optional tile-local scratch, at most 16 MiB even for a direct kernel call. + if len.checked_mul(std::mem::size_of::())? > 16 * 1024 * 1024 { + return None; + } + let mut sites = Vec::new(); + sites.try_reserve_exact(len).ok()?; + let cc = ncol(ctx, n); + let sub = subtractive(ctx); + for gy in y0..=y1 { + for gx in x0..=x1 { + let point = cell_point(gx, gy, cell, seed, origin); + let (sx, sy) = (point.0.floor() as i32, point.1.floor() as i32); + let mut color = [0.0; MAXC]; + let sampled = sample(sx, sy, color.get_mut(..n)?); + let radius = if dots { + let j = (hash01(gx, gy, 17, seed) - 0.5) * 0.08; + for value in color.iter_mut().take(cc) { + *value = (*value + if sub { -j } else { j }).clamp(0.0, 1.0); + } + cell * (0.45 + 0.35 * hash01(gx, gy, 13, seed)) + } else { + 0.0 + }; + sites.push(Site { point, color, radius, sampled }); + } + } + Some(Self { x0, y0, width, sites }) + } + + fn neighbours(&self, cx: i32, cy: i32) -> Option<[usize; 9]> { + let x = usize::try_from(cx.checked_sub(1)?.checked_sub(self.x0)?).ok()?; + let y = usize::try_from(cy.checked_sub(1)?.checked_sub(self.y0)?).ok()?; + if x.checked_add(2)? >= self.width { + return None; + } + let start = y.checked_mul(self.width)?.checked_add(x)?; + let last = start.checked_add(2 * self.width)?.checked_add(2)?; + self.sites.get(last)?; + Some(std::array::from_fn(|i| start + i / 3 * self.width + i % 3)) + } +} + +/// Contiguous x runs with the same base cell. Evaluate the old f32 mapping once +/// per column, rather than use an inverse mapping that could round differently. +fn runs(area: Rect, origin: f32, cell: f32) -> Vec<(i32, i32, i32)> { + let mut runs: Vec<(i32, i32, i32)> = Vec::new(); + for x in area.x0..area.x1 { + let cx = key(x, origin, cell); + if let Some(last) = runs.last_mut() + && last.0 == cx + { + last.2 = x + 1; + } else { + runs.push((cx, x, x + 1)); + } + } + runs +} + +fn nearest(grid: &Grid, neighbours: &[usize; 9], x: i32, y: i32) -> Option { + let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); + let mut best = (f32::MAX, *neighbours.get(4)?); + for &i in neighbours { + let p = grid.sites.get(i)?.point; + let d = (p.0 - fx).powi(2) + (p.1 - fy).powi(2); + if d < best.0 { + best = (d, i); + } + } + Some(best.1) +} + +/// Bounds on the *same f32 score* used by the scalar comparison. Along a +/// scanline, squared x distance has its maximum at an endpoint and its lower +/// bound at the clamped site centre. IEEE subtraction, square, addition and +/// sqrt are monotone on these nonnegative inputs, so these bounds also cover +/// rounding. No estimated bisectors or epsilon decide ownership. +fn score_range(site: &Site, x0: i32, x1: i32, y: i32) -> (f32, f32) { + let left = x0 as f32 + 0.5; + let right = (x1 - 1) as f32 + 0.5; + let dy2 = (site.point.1 - (y as f32 + 0.5)).powi(2); + let near = (site.point.0 - site.point.0.clamp(left, right)).powi(2) + dy2; + let far = (site.point.0 - left).powi(2).max((site.point.0 - right).powi(2)) + dy2; + if DOTS { (site.radius - far.sqrt(), site.radius - near.sqrt()) } else { (-far, -near) } +} + +fn score(site: &Site, x: i32, y: i32) -> f32 { + let d = (site.point.0 - (x as f32 + 0.5)).powi(2) + (site.point.1 - (y as f32 + 0.5)).powi(2); + if DOTS { site.radius - d.sqrt() } else { -d } +} + +/// Certify a whole run when one candidate's worst score beats every other's +/// best. Ambiguous runs split down to scalar pixels. Depth is bounded by the +/// output tile width (at most 11 splits for a 2048-pixel application tile). +fn certified_span(grid: &Grid, ids: &[usize; 9], x0: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> { + let len = owners.len(); + if len == 0 { + return Some(()); + } + let x1 = x0.checked_add(i32::try_from(len).ok()?)?; + let middle = x0 + (len / 2) as i32; + let mut best = (if DOTS { -0.5 } else { -f32::MAX }, None); + for &i in ids { + let value = score::(grid.sites.get(i)?, middle, y); + if value > best.0 { + best = (value, Some(i)); + } + } + if let Some(i) = best.1 { + let site = grid.sites.get(i)?; + let lower = score_range::(site, x0, x1, y).0; + let certified = (!DOTS || lower > -0.5) + && ids.iter().filter(|&&j| j != i).all(|&j| grid.sites.get(j).is_some_and(|other| lower > score_range::(other, x0, x1, y).1)); + if len == 1 || certified { + owners.fill(i as u32); + if DOTS { + if lower >= 0.5 { + depths.fill(0.5); + } else { + for (offset, depth) in depths.iter_mut().enumerate() { + *depth = score::(site, x0 + offset as i32, y); + } + } + } + return Some(()); + } + } else if len == 1 || ids.iter().all(|&i| grid.sites.get(i).is_some_and(|site| score_range::(site, x0, x1, y).1 <= -0.5)) { + owners.fill(u32::MAX); + return Some(()); + } + let half = len / 2; + let (left, right) = owners.split_at_mut(half); + let (ld, rd) = depths.split_at_mut(half); + certified_span::(grid, ids, x0, y, left, ld)?; + certified_span::(grid, ids, x0 + half as i32, y, right, rd) +} + +pub(super) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Option> { + let cell = cell_size.max(2.0); + let n = src.ch; + let b = ctx.bounds; + // Match the scalar averaging window, including floor/ceil at negative origins. + // Decline extreme geometry before its i32 arithmetic; the scalar fallback owns it. + let c0 = ( + (((i64::from(out.x0) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_sub(2)?, + (((i64::from(out.y0) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_sub(2)?, + ); + let c1 = ( + (((i64::from(out.x1) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_add(2)?, + (((i64::from(out.y1) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_add(2)?, + ); + let win = Rect::new( + b.x0.checked_add((c0.0 as f32 * cell).floor() as i32)?, + b.y0.checked_add((c0.1 as f32 * cell).floor() as i32)?, + b.x0.checked_add((c1.0.checked_add(1)? as f32 * cell).ceil() as i32)?, + b.y0.checked_add((c1.1.checked_add(1)? as f32 * cell).ceil() as i32)?, + ) + .intersect(&src.rect) + .intersect(&b); + let mut grid = Grid::new(src, out.union(&win), ctx, cell, seed, false)?; + let origin = (b.x0 as f32, b.y0 as f32); + if grid.sites.iter().any(|s| !s.sampled) { + let mut acc = Vec::new(); + let count = grid.sites.len().checked_mul(n.checked_add(1)?)?; + acc.try_reserve_exact(count).ok()?; + acc.resize(count, 0.0f32); + let columns = runs(win, origin.0, cell); + for y in win.y0..win.y1 { + let cy = key(y, origin.1, cell); + for &(cx, x0, x1) in &columns { + let neighbours = grid.neighbours(cx, cy)?; + // An interior cell's average is never used. Skip the entire run if + // all nine possible winners have an on-image colour sample. + if neighbours.iter().all(|&i| grid.sites.get(i).is_some_and(|s| s.sampled)) { + continue; + } + for x in x0..x1 { + let i = nearest(&grid, &neighbours, x, y)?; + if grid.sites.get(i)?.sampled { + continue; + } + let gx = grid.x0.checked_add((i % grid.width) as i32)?; + let gy = grid.y0.checked_add((i / grid.width) as i32)?; + if gx < c0.0 || gy < c0.1 || gx > c1.0 || gy > c1.1 { + continue; + } + let sum = acc.get_mut(i.checked_mul(n + 1)?..i.checked_add(1)?.checked_mul(n + 1)?)?; + let alpha = if ctx.alpha { src.get(x, y, n - 1) } else { 1.0 }; + for (c, value) in sum.iter_mut().enumerate().take(n) { + let s = src.get(x, y, c); + *value += if ctx.alpha && c + 1 < n { s * alpha } else { s }; + } + *sum.get_mut(n)? += 1.0; + } + } + } + for (site, sum) in grid.sites.iter_mut().zip(acc.chunks_exact(n + 1)) { + let count = *sum.get(n)?; + if !site.sampled && count > 0.0 { + for (dst, value) in site.color.iter_mut().zip(sum).take(n) { + *dst = *value / count; + } + crate::fxutil::unpremul_px(site.color.get_mut(..n)?, ctx.alpha); + site.sampled = true; + } + } + } + crystallize_grid(src, out, ctx, cell, &grid) +} + +fn crystallize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, grid: &Grid) -> Option> { + let n = src.ch; + let b = ctx.bounds; + let origin = (b.x0 as f32, b.y0 as f32); + let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) }; + let width = out.width() as usize; + let stride = width.checked_mul(n)?; + let columns = runs(out, origin.0, cell); + let mut distances = vec![f32::MAX; width]; + let mut owners = vec![0u32; width]; + for (row, pixels) in result.chunks_exact_mut(stride).enumerate() { + let y = out.y0.checked_add(i32::try_from(row).ok()?)?; + let fy = y as f32 + 0.5; + let cy = key(y, origin.1, cell); + distances.fill(f32::MAX); + for &(cx, x0, x1) in &columns { + let neighbours = grid.neighbours(cx, cy)?; + let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?; + let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?; + if cell >= 32.0 && out.width() <= 2048 { + certified_span::(grid, &neighbours, x0, y, owners.get_mut(start..end)?, distances.get_mut(start..end)?)?; + continue; + } + owners.get_mut(start..end)?.fill(*neighbours.get(4)? as u32); + for &i in &neighbours { + let site = grid.sites.get(i)?; + let dy2 = (site.point.1 - fy).powi(2); + for (offset, (distance, owner)) in distances.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() { + let fx = (x0 + offset as i32) as f32 + 0.5; + let d = (site.point.0 - fx).powi(2) + dy2; + if d < *distance { + *distance = d; + *owner = i as u32; + } + } + } + } + for (x, (pixel, &owner)) in pixels.chunks_exact_mut(n).zip(&owners).enumerate() { + if !b.contains(out.x0 + x as i32, y) { + continue; + } + let site = grid.sites.get(owner as usize)?; + if site.sampled { + pixel.copy_from_slice(site.color.get(..n)?); + } + } + } + Some(result) +} + +/// A site may only compete in its own base column and its two neighbours. +/// Derive the inclusive pixel runs from the exact forward f32 cell mapping. +fn influence_columns(out: Rect, origin: f32, cell: f32, grid: &Grid) -> Option> { + let columns = runs(out, origin, cell); + (0..grid.width) + .map(|column| { + let gx = grid.x0.checked_add(i32::try_from(column).ok()?)?; + let lo = columns.partition_point(|&(cx, _, _)| cx < gx - 1); + let hi = columns.partition_point(|&(cx, _, _)| cx <= gx + 1); + if lo == hi { + return Some((out.x0, out.x0)); + } + Some((columns.get(lo)?.1, columns.get(hi - 1)?.2)) + }) + .collect() +} + +pub(super) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Option> { + let cell = cell_size.max(2.0); + // This path uses integer pixel centres and conservative one-pixel support + // margins. Beyond exact half-integer f32 coordinates use the scalar kernel. + if cell > 4096.0 || [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000) { + return None; + } + let grid = Grid::new(src, out, ctx, cell, seed, true)?; + pointillize_grid(src, out, ctx, cell, background, &grid) +} + +fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f32; 4], grid: &Grid) -> Option> { + let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32); + let bg = native(ctx, background); + let n = src.ch; + let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) }; + let columns = influence_columns(out, origin.0, cell, grid)?; + let base_runs = runs(out, origin.0, cell); + let width = out.width() as usize; + let stride = width.checked_mul(n)?; + let mut depths = vec![-0.5f32; width]; + let mut owners = vec![u32::MAX; width]; + for (row, pixels) in result.chunks_exact_mut(stride).enumerate() { + let y = out.y0.checked_add(i32::try_from(row).ok()?)?; + let fy = y as f32 + 0.5; + let cy = key(y, origin.1, cell); + depths.fill(-0.5); + owners.fill(u32::MAX); + if cell >= 32.0 && out.width() <= 2048 { + for &(cx, x0, x1) in &base_runs { + let ids = grid.neighbours(cx, cy)?; + let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?; + let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?; + certified_span::(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?; + } + } else { + for gy in cy.checked_sub(1)?..=cy.checked_add(1)? { + let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?; + // Global gy/gx order is the original dy/dx tie order at every pixel. + for (column, &(x0, x1)) in columns.iter().enumerate() { + if x0 >= x1 { + continue; + } + let i = row.checked_add(column)?; + let site = grid.sites.get(i)?; + let dy2 = (site.point.1 - fy).powi(2); + let reach = f64::from(site.radius) + 1.5; + let square = reach * reach - f64::from(dy2); + if square < 0.0 { + continue; + } + // The extra pixel covers subtraction/square/sqrt rounding for + // the bounded coordinates/radii above. The exact old depth + // comparison still decides every candidate that survives. + let reach_x = square.max(0.0).sqrt() + 1.0; + let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0); + let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1); + if lo >= hi { + continue; + } + let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?; + let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?; + for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() { + let fx = (lo + offset as i32) as f32 + 0.5; + let d = ((site.point.0 - fx).powi(2) + dy2).sqrt(); + let candidate = site.radius - d; + if candidate > *depth { + *depth = candidate; + *owner = i as u32; + } + } + } + } + } + for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) { + let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 }; + let mut canvas = bg; + if ctx.alpha { + *canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0)); + } + if owner != u32::MAX { + let i = owner as usize; + let k = (depth + 0.5).clamp(0.0, 1.0); + let dot = grid.sites.get(i)?; + for (c, value) in canvas.iter_mut().enumerate().take(n) { + *value += (*dot.color.get(c)? - *value) * k; + } + } + pixel.copy_from_slice(canvas.get(..n)?); + } + } + Some(result) +} + +/// The filters sample cell centres, not a dense neighbourhood. On ordinary +/// tiles read those sparse samples directly; keep only the original output +/// pixels for alpha and selection mixing. Crystallize boundary tiles still +/// use the dense path, whose row-order f32 averages are part of its result. +#[allow(clippy::too_many_arguments)] +pub(crate) fn tile( + surface: &photocraft_raster::Surface, + out: Rect, + ctx: &Ctx, + cell: f32, + seed: u32, + background: Option<[f32; 4]>, + extent: Option, +) -> Option<(Image, Vec)> { + if !cell.is_finite() + || !(2.0..=300.0).contains(&cell) + || out.is_empty() + || out.width() > 2048 + || out.height() > 2048 + || [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000) + || extent.is_some_and(|e| e.is_empty() || out.intersect(&e) != out) + { + return None; + } + let dots = background.is_some(); + let grid = Grid::new_with(out, ctx, surface.channels(), cell, seed, dots, |sx, sy, color| { + let (x, y) = match extent { + Some(e) => (sx.clamp(e.x0, e.x1 - 1), sy.clamp(e.y0, e.y1 - 1)), + None => (sx, sy), + }; + surface.read_pixel(x, y, color); + ctx.bounds.contains(sx, sy) + })?; + if !dots && grid.sites.iter().any(|s| !s.sampled) { + return None; + } + let original = Image::read(surface, out); + let data = match background { + Some(bg) => pointillize_grid(&original, out, ctx, cell, bg, &grid)?, + None => crystallize_grid(&original, out, ctx, cell, &grid)?, + }; + Some((original, data)) +} + +/// Same rounding and native byte order as Surface::write_region, before the +/// worker hands its result back. Bounds the allocation and retains float data +/// if an optional encode buffer cannot be reserved. +pub(crate) fn encode(format: photocraft_color::PixelFormat, samples: &[f32]) -> Option> { + use photocraft_color::SampleType; + let mut bytes = Vec::new(); + let len = samples.len().checked_mul(format.sample.bytes())?; + bytes.try_reserve_exact(len).ok()?; + bytes.resize(len, 0); + match format.sample { + SampleType::U8 => { + for (dst, value) in bytes.iter_mut().zip(samples) { + *dst = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + SampleType::U16 => { + for (dst, value) in bytes.as_chunks_mut::<2>().0.iter_mut().zip(samples) { + dst.copy_from_slice(&((value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_ne_bytes()); + } + } + SampleType::F32 => { + for (dst, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(samples) { + dst.copy_from_slice(&value.to_ne_bytes()); + } + } + } + Some(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_raster::Surface; + + #[test] + fn span_certificates_keep_discrete_winners_and_antialias_coverage() { + for origin in [-16_777_210, -3_999_000, -37, 0, 3_999_000, 16_777_210] { + let out = Rect::new(origin, -3, origin + 601, 4); + let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true }; + let src = Image::new(out, 4); + for cell in [32.0, 33.25, 100.0, 300.0, 4096.0] { + for seed in 0..32 { + let grid = Grid::new(&src, out, &ctx, cell, seed, true).unwrap(); + for y in out.y0..out.y1 { + let cy = key(y, ctx.bounds.y0 as f32, cell); + for (cx, x0, x1) in runs(out, ctx.bounds.x0 as f32, cell) { + let ids = grid.neighbours(cx, cy).unwrap(); + for dots in [false, true] { + let mut owners = vec![u32::MAX; (x1 - x0) as usize]; + let mut depths = vec![-0.5; owners.len()]; + if dots { + certified_span::(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap(); + } else { + certified_span::(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap(); + } + for (offset, (&owner, &depth)) in owners.iter().zip(&depths).enumerate() { + let x = x0 + offset as i32; + let mut best = (if dots { -0.5 } else { -f32::MAX }, u32::MAX); + for &i in &ids { + let value = if dots { score::(&grid.sites[i], x, y) } else { score::(&grid.sites[i], x, y) }; + if value > best.0 { + best = (value, i as u32); + } + } + assert_eq!(owner, best.1, "x={x} y={y} cell={cell} seed={seed}"); + if dots && owner != u32::MAX { + assert_eq!((depth + 0.5).clamp(0.0, 1.0).to_bits(), (best.0 + 0.5).clamp(0.0, 1.0).to_bits()); + } + } + } + } + } + } + } + } + } + + #[test] + fn worker_encoding_is_surface_write_region_at_every_depth() { + let values = [f32::NAN, f32::NEG_INFINITY, -100.0, -0.0, f32::from_bits(1), 0.5 / 65535.0, 0.5 / 255.0, 0.5, 1.0, 1.1, f32::INFINITY]; + let area = Rect::new(-17, -9, 281, 2); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [false, true] { + let format = PixelFormat::new(mode, sample, alpha); + let data: Vec<_> = (0..area.width() as usize * area.height() as usize * format.channels()).map(|i| values[i % values.len()]).collect(); + let mut surface = Surface::new(format); + surface.write_region(area, &data); + assert_eq!(encode(format, &data).unwrap(), surface.to_interleaved(area)); + } + } + } + } + + #[test] + fn optional_grid_rejects_unbounded_or_incomplete_scratch() { + let area = Rect::new(0, 0, 100_000, 100_000); + let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true }; + let image = Image { rect: area, ch: 4, data: Vec::new() }; + assert!(Grid::new(&image, area, &ctx, 2.0, 0, true).is_none()); + assert!(Grid::new_with(area, &ctx, 4, 2.0, 0, true, |_, _, _| false).is_none()); + assert!(Grid::new_with(area, &ctx, 0, 2.0, 0, true, |_, _, _| false).is_none()); + assert!(Grid::new_with(area, &ctx, MAXC + 1, 2.0, 0, true, |_, _, _| false).is_none()); + assert!(Grid::new_with(area, &ctx, 4, f32::INFINITY, 0, true, |_, _, _| false).is_none()); + } +} diff --git a/crates/algo/src/pixelate/tests.rs b/crates/algo/src/pixelate/tests.rs index c82d747326..f0339e19c9 100644 --- a/crates/algo/src/pixelate/tests.rs +++ b/crates/algo/src/pixelate/tests.rs @@ -191,3 +191,79 @@ fn facet_scratch_rejects_large_or_incomplete_storage() { let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() }; assert!(facet::run(&src, out, &ctx).is_none()); } + +#[test] +fn cell_filters_match_scalar_models_edges_and_fractional_cells() { + let bounds = Rect::new(-19, -13, 32, 29); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Multichannel] { + for alpha in [false, true] { + let ctx = Ctx { bounds, mode, alpha }; + for pattern in 0..5 { + for halo in [0, 7] { + let src = fixture(bounds.inflate(halo), &ctx, pattern); + for out in [bounds, Rect::new(-23, -15, 37, 31), Rect::new(-7, 2, 18, 13)] { + for cell in [2.0, 3.75, 10.0, 100.0, 300.0] { + for seed in [0, 37, u32::MAX] { + let got = cells::crystallize(&src, out, &ctx, cell, seed).unwrap(); + assert_bits(&got, &crystallize_direct(&src, out, &ctx, cell, seed)); + for bg in [[1.0; 4], [0.1, 0.8, 0.4, 0.25]] { + let got = cells::pointillize(&src, out, &ctx, cell, seed, bg).unwrap(); + assert_bits(&got, &pointillize_direct(&src, out, &ctx, cell, seed, bg)); + } + } + } + } + } + } + } + } +} + +#[test] +fn cell_filters_tiled_storage_matches_scalar_with_soft_selection() { + use crate::{FilterParams, apply_tiled}; + let bounds = Rect::new(-17, -9, 86, 78); + 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 ctx = Ctx { bounds, mode, alpha }; + let src = fixture(bounds, &ctx, 2); + let mut surface = Surface::new(PixelFormat::new(mode, depth, alpha)); + surface.write_region(bounds, &src.data); + let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let coverage: Vec<_> = (0..bounds.width() * bounds.height()).map(|i| (i % 7) as f32 / 6.0).collect(); + selection.write_region(bounds, &coverage); + for params in [ + FilterParams::Crystallize { cell_size: 3.75, seed: 37 }, + FilterParams::Crystallize { cell_size: 10.0, seed: 37 }, + FilterParams::Pointillize { cell_size: 3.75, seed: 37, background: [1.0; 4] }, + FilterParams::Pointillize { cell_size: 10.0, seed: 37, background: [0.1, 0.8, 0.4, 0.25] }, + ] { + for extent in [None, Some(bounds)] { + assert!(matches!(params.halo(), crate::Halo::Radius(_))); + let halo = match params.halo() { + crate::Halo::Radius(r) => r, + _ => 0, + }; + let image = match extent { + Some(e) => Image::read_clamped(&surface, bounds.inflate(halo), e), + None => Image::read(&surface, bounds.inflate(halo)), + }; + let mut expected_data = match params { + FilterParams::Crystallize { cell_size, seed } => crystallize_direct(&image, bounds, &ctx, cell_size, seed), + FilterParams::Pointillize { cell_size, seed, background } => pointillize_direct(&image, bounds, &ctx, cell_size, seed, background), + _ => Vec::new(), + }; + crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha); + let mut expected = surface.clone(); + expected.write_region(bounds, &expected_data); + for tile in [1, 7, 17, 256] { + let got = apply_tiled(&surface, ¶ms, bounds, bounds, Some(&selection), tile, extent); + assert_bits(&got.read_region(bounds), &expected.read_region(bounds)); + } + } + } + } + } + } +} diff --git a/crates/algo/src/quantize.rs b/crates/algo/src/quantize.rs index 0d428f3086..70d5b5c90c 100644 --- a/crates/algo/src/quantize.rs +++ b/crates/algo/src/quantize.rs @@ -61,8 +61,15 @@ pub enum Dither { Noise, } +/// `(v.clamp(0, 1) * 255).round() as u8`, without the libm `roundf` call per channel (that call +/// was about a tenth of the palette scan on a large image). `x` is in 0..=255, so truncating +/// with an integer cast and adding one when the exact remainder is at least a half is +/// round-half-away-from-zero, the same as `f32::round`; NaN gives 0 in both. The +/// `to8_matches_round_for_every_float` test checks every input. fn to8(v: f32) -> u8 { - (v.clamp(0.0, 1.0) * 255.0).round() as u8 + let x = v.clamp(0.0, 1.0) * 255.0; + let t = x as u32; + (t + u32::from(x - t as f32 >= 0.5)) as u8 } fn rgb8(p: &[f32; 4]) -> Rgb8 { @@ -166,15 +173,116 @@ fn forced_colors(f: Forced) -> Vec { } } -/// Unique 8-bit colours with counts (opaque pixels only). +/// Unique 8-bit colours with counts (opaque pixels only), sorted by colour. fn histogram(px: &[[f32; 4]]) -> Vec<(Rgb8, u32)> { - let mut m: std::collections::HashMap = std::collections::HashMap::new(); - for p in px.iter().filter(|p| p[3] >= 0.5) { - *m.entry(rgb8(p)).or_default() += 1; + // Pack each opaque pixel into one u32 (r, g, b from the top, so the numeric order is the + // `[u8; 3]` order), sort the plain integers and count the runs. This replaces a SipHash + // `HashMap` insert per pixel: counts are integers, so any order of work gives the same + // result, and equal colours sit next to each other after the sort. One 4-byte value per + // pixel is the only large allocation (no per-thread tables). + let pack = |p: &[f32; 4]| { + let [r, g, b] = rgb8(p); + u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b) + }; + #[cfg(not(target_arch = "wasm32"))] + let keys = { + use rayon::prelude::*; + let mut keys: Vec = px.par_iter().filter(|p| p[3] >= 0.5).map(pack).collect(); + keys.par_sort_unstable(); + keys + }; + #[cfg(target_arch = "wasm32")] + let keys = { + let mut keys: Vec = px.iter().filter(|p| p[3] >= 0.5).map(pack).collect(); + keys.sort_unstable(); + keys + }; + let mut out: Vec<(Rgb8, u32)> = Vec::new(); + for k in keys { + let c = [(k >> 16) as u8, (k >> 8) as u8, k as u8]; + match out.last_mut() { + Some((last, n)) if *last == c => *n = n.saturating_add(1), + _ => out.push((c, 1)), + } + } + out +} + +/// Widest axis of `b` and its split score (weighted extent), or `None` for a box that cannot +/// be split. Every score is bit for bit what the old per-iteration scan gave. +fn split_score(b: &[([f32; 3], u32)]) -> Option<(usize, f32)> { + if b.len() <= 1 { + return None; + } + // One pass per axis, then the weight sum: the same folds the old per-iteration scan ran + // (measured faster than a single fused pass, which the compiler does not vectorise). + let mut best = (0usize, -1.0f32); + for a in 0..3 { + let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); + if hi - lo > best.1 { + best = (a, hi - lo); + } + } + let w: f32 = b.iter().map(|p| p.1 as f32).sum(); + Some((best.0, best.1 * w.sqrt())) +} + +/// Maps an f32 to a u32 whose unsigned order is exactly `f32::total_cmp` order. +fn total_order_key(v: f32) -> u32 { + let bits = v.to_bits(); + // Negative: flip every bit (reverses their order, puts them below). Positive: set the sign bit. + bits ^ ((((bits as i32) >> 31) as u32) | 0x8000_0000) +} + +/// Stable LSD radix sort of `b` along `axis`, in the order of `f32::total_cmp`. Stable like +/// `sort_by`, so ties keep their original order and the result is element for element the same; +/// it is several times faster than a comparison sort on millions of points. +fn radix_sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) { + let n = b.len(); + let Some(&fill) = b.first() else { return }; + if n < 2 { + return; + } + // Three digits of 11, 11 and 10 bits; count all of them in one read. + let mut counts = [[0u32; 2048]; 3]; + for e in b.iter() { + let k = total_order_key(e.0[axis]); + counts[0][(k & 0x7ff) as usize] += 1; + counts[1][((k >> 11) & 0x7ff) as usize] += 1; + counts[2][(k >> 22) as usize] += 1; + } + let mut src = std::mem::take(b); + let mut dst = vec![fill; n]; + for (pass, count) in counts.iter_mut().enumerate() { + // A digit that is the same for every point would not move anything. + if count.iter().any(|&c| c as usize == n) { + continue; + } + let mut sum = 0u32; + for c in count.iter_mut() { + let here = *c; + *c = sum; + sum += here; + } + let shift = 11 * pass as u32; + for e in &src { + let d = ((total_order_key(e.0[axis]) >> shift) & 0x7ff) as usize; + dst[count[d] as usize] = *e; + count[d] += 1; + } + std::mem::swap(&mut src, &mut dst); + } + *b = src; +} + +/// Stable sort of `b` along `axis`. Boxes this small are quicker with `sort_by` than with the +/// radix sort's fixed counting cost. +fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) { + if b.len() >= 256 { + radix_sort_along(b, axis); + } else { + b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); } - let mut v: Vec<(Rgb8, u32)> = m.into_iter().collect(); - v.sort_unstable(); - v } /// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means. @@ -183,29 +291,23 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { return Vec::new(); } let mut boxes: Vec> = vec![pts.to_vec()]; + // Each box's (axis, score), kept in lockstep with `boxes` (same swap_remove and pushes). + // Only the two boxes made by a split change, so only those are scanned again; rescanning + // every box on every iteration was most of the time here. + let mut scores: Vec> = vec![split_score(pts)]; while boxes.len() < n { - // Split the box with the largest weighted extent. - let (bi, axis, score) = boxes + // Split the box with the largest weighted extent (first one wins a tie, as before). + let (bi, axis, score) = scores .iter() .enumerate() - .filter(|(_, b)| b.len() > 1) - .map(|(i, b)| { - let mut best = (0usize, -1.0f32); - for a in 0..3 { - let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); - if hi - lo > best.1 { - best = (a, hi - lo); - } - } - let w: f32 = b.iter().map(|p| p.1 as f32).sum(); - (i, best.0, best.1 * w.sqrt()) - }) + .filter_map(|(i, s)| s.map(|(axis, score)| (i, axis, score))) .fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc }); if bi == usize::MAX || score <= 0.0 { break; } let mut b = boxes.swap_remove(bi); - b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + scores.swap_remove(bi); + sort_along(&mut b, axis); let total: u64 = b.iter().map(|p| u64::from(p.1)).sum(); let mut acc = 0u64; let mut cut = 1; @@ -217,6 +319,8 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { } } let rest = b.split_off(cut); + scores.push(split_score(&b)); + scores.push(split_score(&rest)); boxes.push(b); boxes.push(rest); } @@ -239,8 +343,18 @@ fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) { let lookup = Nearest::new(centers.iter().copied()); let mut sum = vec![[0.0f64; 3]; centers.len()]; let mut cnt = vec![0.0f64; centers.len()]; - for (p, w) in pts { - let i = lookup.find(*p); + // The nearest-centre search is the expensive part and has no cross-point state, so it + // runs in parallel. The f64 sums below stay sequential in the original point order: + // float addition is not associative, and this keeps every bit of the result unchanged. + #[cfg(not(target_arch = "wasm32"))] + let nearest: Vec = { + use rayon::prelude::*; + pts.par_iter().map(|(p, _)| lookup.find(*p) as u32).collect() + }; + #[cfg(target_arch = "wasm32")] + let nearest: Vec = pts.iter().map(|(p, _)| lookup.find(*p) as u32).collect(); + for ((p, w), &i) in pts.iter().zip(&nearest) { + let i = i as usize; for a in 0..3 { sum[i][a] += f64::from(p[a]) * f64::from(*w); } @@ -258,6 +372,20 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 { photocraft_color::convert::lab_to_srgb(lab).map(to8) } +/// Histogram colours as CIE Lab points with their counts. Each point depends only on its own +/// colour and `collect` keeps the order, so the parallel map gives the same list; the log/pow +/// in `srgb_to_lab` made this a visible serial cost with millions of unique colours. +fn lab_points(hist: &[(Rgb8, u32)]) -> Vec<([f32; 3], u32)> { + let lab = |(c, n): &(Rgb8, u32)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + hist.par_iter().map(lab).collect() + } + #[cfg(target_arch = "wasm32")] + hist.iter().map(lab).collect() +} + /// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed /// kinds; forced colours are included first and count toward it. pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result, String> { @@ -296,7 +424,7 @@ pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: if hist.len() <= room { hist.iter().map(|h| h.0).collect() } else { - let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect(); + let pts = lab_points(&hist); let mut centers = median_cut(&pts, room); kmeans(&pts, &mut centers, 4); let mut v: Vec = centers.iter().map(|c| lab_to_rgb8(*c)).collect(); @@ -610,6 +738,253 @@ mod tests { (0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect() } + fn to8_reference(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 + } + + #[test] + fn to8_matches_round_on_samples_and_edges() { + // Every half-way point and its neighbours, plus a dense sweep of the unit range. + for k in 0..=256u32 { + let mid = (k as f32 + 0.5) / 255.0; + for d in -3i32..=3 { + let v = f32::from_bits((mid.to_bits() as i32 + d) as u32); + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + } + for i in 0..200_000u32 { + let v = f32::from_bits(i * 20_000); + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, 1.0, -1.0, 2.0, 1e30, -1e30, f32::MIN_POSITIVE, 1e-45] { + assert_eq!(to8(v), to8_reference(v), "{v:e}"); + } + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + #[ignore = "checks all 2^32 floats, a few seconds in release; run with --release --ignored"] + fn to8_matches_round_for_every_float() { + use rayon::prelude::*; + let bad = (0..=u32::MAX).into_par_iter().filter(|&b| to8(f32::from_bits(b)) != to8_reference(f32::from_bits(b))).count(); + assert_eq!(bad, 0); + } + + /// A repeatable 96x64 image: a gradient with up to 31 levels of integer-hash noise and a + /// few see-through pixels. + fn golden_image() -> Vec<[f32; 4]> { + let (w, h) = (96usize, 64usize); + (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let noise = |salt: u32| ((((i as u32).wrapping_mul(2_654_435_761).wrapping_add(salt.wrapping_mul(40_503))) >> 13) & 31) as f32 / 255.0; + let alpha = if i % 17 == 0 { 0.25 } else { 1.0 }; + [x / (w - 1) as f32 * 0.8 + noise(1), y / (h - 1) as f32 * 0.7 + noise(2), ((x + y) / (w + h) as f32) * 0.6 + noise(3), alpha] + }) + .collect() + } + + /// Palettes written down from the version before the speed-up (hash-map histogram, every box + /// rescanned each split, comparison sort, serial k-means). Any change to how the palettes + /// are built has to give these exact colours. + #[test] + fn palettes_match_the_recorded_originals() { + let px = golden_image(); + #[rustfmt::skip] + let cases: Vec<(PaletteKind, usize, Forced, &[Rgb8])> = vec![ + ( + PaletteKind::Adaptive, + 16, + Forced::None, + &[ + [40, 173, 80], [196, 175, 150], [92, 173, 103], [92, 81, 71], [195, 83, 118], [39, 82, 49], + [142, 82, 95], [195, 37, 101], [143, 36, 79], [40, 128, 64], [196, 129, 133], [92, 127, 87], + [91, 36, 56], [38, 35, 33], [144, 128, 111], [144, 174, 127], + ], + ), + ( + PaletteKind::Perceptual, + 16, + Forced::None, + &[ + [104, 111, 87], [51, 129, 68], [48, 178, 79], [110, 74, 77], [165, 46, 93], [205, 50, 107], + [200, 154, 143], [44, 54, 42], [190, 101, 121], [61, 32, 44], [155, 115, 111], [48, 93, 56], + [119, 42, 71], [84, 172, 98], [126, 165, 115], [172, 168, 135], + ], + ), + ( + PaletteKind::Selective, + 24, + Forced::BlackWhite, + &[ + [0, 0, 0], [255, 255, 255], [102, 107, 85], [65, 69, 55], [87, 133, 89], [109, 73, 76], + [164, 42, 92], [204, 51, 102], [199, 153, 142], [54, 170, 84], [199, 107, 126], [65, 33, 45], + [153, 102, 102], [187, 180, 145], [40, 106, 54], [100, 176, 107], [138, 174, 123], [50, 188, 80], + [32, 37, 33], [47, 139, 69], [144, 143, 116], [41, 80, 49], [108, 33, 64], [151, 70, 94], + ], + ), + ( + PaletteKind::Adaptive, + 8, + Forced::Primaries, + &[ + [0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255], + [255, 0, 255], [255, 255, 0], + ], + ), + ( + PaletteKind::Perceptual, + 12, + Forced::BlackWhite, + &[ + [0, 0, 0], [255, 255, 255], [104, 106, 85], [137, 43, 79], [148, 170, 126], [181, 144, 132], + [66, 172, 87], [88, 49, 60], [57, 118, 67], [42, 54, 41], [183, 101, 118], [196, 50, 105], + ], + ), + ]; + for (kind, colors, forced, expected) in cases { + assert_eq!(build_palette(&px, kind, colors, forced).unwrap(), expected, "{kind:?} {colors} {forced:?}"); + } + } + + /// The pre-speed-up median cut and k-means, kept as the oracle for the faster ones. + fn median_cut_reference(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> { + if pts.is_empty() || n == 0 { + return Vec::new(); + } + let mut boxes: Vec> = vec![pts.to_vec()]; + while boxes.len() < n { + let (bi, axis, score) = boxes + .iter() + .enumerate() + .filter(|(_, b)| b.len() > 1) + .map(|(i, b)| { + let mut best = (0usize, -1.0f32); + for a in 0..3 { + let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a]))); + if hi - lo > best.1 { + best = (a, hi - lo); + } + } + let w: f32 = b.iter().map(|p| p.1 as f32).sum(); + (i, best.0, best.1 * w.sqrt()) + }) + .fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc }); + if bi == usize::MAX || score <= 0.0 { + break; + } + let mut b = boxes.swap_remove(bi); + b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + let total: u64 = b.iter().map(|p| u64::from(p.1)).sum(); + let mut acc = 0u64; + let mut cut = 1; + for (i, p) in b.iter().enumerate() { + acc += u64::from(p.1); + if acc * 2 >= total { + cut = (i + 1).clamp(1, b.len() - 1); + break; + } + } + let rest = b.split_off(cut); + boxes.push(b); + boxes.push(rest); + } + boxes + .iter() + .map(|b| { + let w: f64 = b.iter().map(|p| f64::from(p.1)).sum(); + std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::() / w) as f32) + }) + .collect() + } + + fn kmeans_reference(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) { + for _ in 0..iters { + let lookup = Nearest::new(centers.iter().copied()); + let mut sum = vec![[0.0f64; 3]; centers.len()]; + let mut cnt = vec![0.0f64; centers.len()]; + for (p, w) in pts { + let i = lookup.find(*p); + for a in 0..3 { + sum[i][a] += f64::from(p[a]) * f64::from(*w); + } + cnt[i] += f64::from(*w); + } + for (i, c) in centers.iter_mut().enumerate() { + if cnt[i] > 0.0 { + *c = std::array::from_fn(|a| (sum[i][a] / cnt[i]) as f32); + } + } + } + } + + /// Points with many repeated coordinates (ties), negative values, -0.0 and weights up to + /// a few thousand, so the stable-sort and float-order rules are all exercised. + fn tie_heavy_points(n: usize, seed: u64) -> Vec<([f32; 3], u32)> { + let mut s = seed; + let mut next = move || { + s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (s >> 33) as u32 + }; + (0..n) + .map(|_| { + let mut c = |range: u32| match next() % 40 { + 0 => -0.0, + 1 => 0.0, + _ => (next() % range) as f32 * 0.5 - (range / 4) as f32, + }; + ([c(24), c(300), c(150)], 1 + next() % 3000) + }) + .collect() + } + + #[test] + fn faster_median_cut_and_kmeans_equal_the_originals_bit_for_bit() { + // 40 000 points cross the 256-point radix threshold on the first splits. + for (n, boxes, seed) in [(40_000, 256, 1), (40_000, 37, 2), (3_000, 256, 3), (200, 64, 4), (2, 8, 5)] { + let pts = tie_heavy_points(n, seed); + let new = median_cut(&pts, boxes); + let old = median_cut_reference(&pts, boxes); + assert_eq!(new.len(), old.len()); + for (a, b) in new.iter().zip(&old) { + assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "median cut, n={n} boxes={boxes}"); + } + let (mut a, mut b) = (new, old); + kmeans(&pts, &mut a, 3); + kmeans_reference(&pts, &mut b, 3); + for (x, y) in a.iter().zip(&b) { + assert_eq!(x.map(f32::to_bits), y.map(f32::to_bits), "kmeans, n={n} boxes={boxes}"); + } + } + } + + #[test] + fn radix_sort_is_the_stable_total_cmp_sort() { + for axis in 0..3 { + let mut pts = tie_heavy_points(5_000, 9 + axis as u64); + // The weight field doubles as an id: equal keys must keep their input order. + for (i, p) in pts.iter_mut().enumerate() { + p.1 = i as u32; + } + pts[10].0[axis] = f32::NAN; + pts[11].0[axis] = -f32::NAN; + pts[12].0[axis] = f32::INFINITY; + pts[13].0[axis] = f32::NEG_INFINITY; + let mut expected = pts.clone(); + expected.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis])); + radix_sort_along(&mut pts, axis); + let bits = |v: &[([f32; 3], u32)]| v.iter().map(|p| (p.0.map(f32::to_bits), p.1)).collect::>(); + assert_eq!(bits(&pts), bits(&expected), "axis {axis}"); + } + } + + #[test] + fn histogram_counts_opaque_pixels_sorted() { + let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.5], [1.0, 0.0, 0.0, 0.49], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]]; + assert_eq!(histogram(&px), vec![([0, 0, 0], 1), ([0, 0, 255], 2), ([255, 0, 0], 2)]); + assert!(histogram(&[]).is_empty()); + } + #[test] fn fixed_palettes_have_expected_sizes() { assert_eq!(web_palette().len(), 216); diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index bd728e379d..4ad84a10c0 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -159,8 +159,37 @@ fn cover(o: i32, scale: f64) -> (i32, i32) { (a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32) } -/// Unclamped weights of output sample `o` over source samples `lo..lo + len`. -fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec) { +/// The source samples worth a weight each: the edge, or the content with one sample of slack on +/// each side to carry the weight of everything beyond it (read as the default pixel). +fn keep_range(edge: Option<(i32, i32)>, content: (i32, i32)) -> (i64, i64) { + let (a, b) = edge.unwrap_or((content.0.saturating_sub(1), content.1.saturating_add(1))); + (i64::from(a), i64::from(b.max(a))) +} + +/// Adds `v` spread evenly over the source samples `[a, b)` to `w`, which holds the samples +/// from `lo`: samples beyond either end of it add to that end slot instead of needing a slot. +fn spread(w: &mut [f64], lo: i64, a: i64, b: i64, v: f64) { + let Some(last) = w.len().checked_sub(1) else { return }; + let each = v / (b - a).max(1) as f64; + let end = lo + w.len() as i64; + let (ia, ib) = (a.max(lo), b.min(end)); + if ia < ib { + for slot in w.get_mut((ia - lo) as usize..(ib - lo) as usize).into_iter().flatten() { + *slot += each; + } + } + if a < lo { + w[0] += each * (b.min(lo) - a) as f64; + } + if b > end { + w[last] += each * (b - a.max(end)) as f64; + } +} + +/// Weights of output sample `o` over source samples `lo..lo + len`, unclamped except that +/// reductions fold what lies beyond the edge (or the `content`) onto its ends, so a tiny scale +/// doesn't cost a weight per source sample it covers. +fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> (i32, Vec) { match kernel { Kernel::Nearest => { let u = (o as f64 + 0.5) / scale - 0.5; @@ -168,7 +197,11 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32 } Kernel::Bilinear if scale < 0.5 => { let (a, b) = cover(o, scale); - (a, vec![1.0 / (b - a) as f64; (b - a) as usize]) + let (ka, kb) = keep_range(edge, content); + let (lo, hi) = (i64::from(a).clamp(ka, kb), (i64::from(b) - 1).clamp(ka, kb)); + let mut w = vec![0.0; (hi - lo + 1) as usize]; + spread(&mut w, lo, i64::from(a), i64::from(b), 1.0); + (lo as i32, w) } Kernel::Bilinear => { let u = position(o, scale, edge); @@ -179,18 +212,18 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32 // Averaged down to four times the target first (pixels of `inner`), then filtered. let inner = 4.0 * scale; let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1)); - let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge); + let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge, (i32::MIN / 4, i32::MAX / 4)); let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a))); let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect(); - let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0); - let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo); - let mut w = vec![0.0; (hi - lo).max(0) as usize]; + let (ka, kb) = keep_range(edge, content); + let lo = i64::from(boxes.iter().map(|b| b.0).min().unwrap_or(0)); + let hi = i64::from(boxes.iter().map(|b| b.1).max().unwrap_or(lo as i32)); + let (klo, khi) = (lo.clamp(ka, kb), (hi - 1).clamp(ka, kb)); + let mut w = vec![0.0; (khi - klo + 1) as usize]; for ((a, b), v) in boxes.into_iter().zip(kw) { - for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() { - *slot += v / (b - a) as f64; - } + spread(&mut w, klo, i64::from(a), i64::from(b), v); } - (lo, w) + (klo as i32, w) } Kernel::Keys { a, stretch } => { let u = position(o, scale, edge); @@ -225,7 +258,7 @@ fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32 let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel }; (dst0..dst1) .map(|o| { - let (lo, w) = weights(o, scale, kernel, edge); + let (lo, w) = weights(o, scale, kernel, edge, content); let hi = lo + w.len() as i32 - 1; // Fold the weights of taps beyond the edge onto the edge sample. let start = clamp(lo); @@ -724,6 +757,22 @@ mod tests { assert!((p[0] - 0.5).abs() < 0.05, "{p:?}"); } + /// A reduction by a tiny scale covers millions of source samples per output sample; only the + /// ones in the content get a weight, the rest fold onto its ends or into `outside`. + #[test] + fn tiny_scales_do_not_get_a_weight_per_covered_sample() { + for kernel in [Kernel::Bilinear, Kernel::Keys { a: -0.5, stretch: 1.0 }] { + for edge in [None, Some((0, 99))] { + let t = taps(0, 3, 1e-6, kernel, edge, (0, 99)); + for t in &t { + assert!(t.weights.len() <= 100, "{}", t.weights.len()); + let total = t.weights.iter().sum::() + t.outside; + assert!((total - 1.0).abs() < 1e-3, "{total}"); + } + } + } + } + #[test] fn transparent_edges_do_not_darken() { let mut s = Surface::new(PixelFormat::RGBA32F); diff --git a/crates/algo/src/seam.rs b/crates/algo/src/seam.rs index 97619c7406..b85fba5bab 100644 --- a/crates/algo/src/seam.rs +++ b/crates/algo/src/seam.rs @@ -9,16 +9,26 @@ //! //! A protect map (`0..=1`, e.g. an alpha channel or detected skin tones) adds a large energy so //! seams avoid protected pixels. Buffers are interleaved `w × h × ch` normalised floats of any -//! colour model and depth (alpha is carried like any other channel). Deterministic. +//! colour model and depth (alpha is carried like any other channel). Deterministic on every +//! platform: an energy that isn't a number (from NaN samples, or infinite ones next to each other +//! or at the border) is the highest energy, and NaN protection protects nothing, so no NaN (whose +//! sign and payload arithmetic does not keep consistently across builds) reaches the seam search. use photocraft_raster::{Cancelled, Interrupt}; /// Energy added per unit of protection: far above any gradient magnitude. const PROTECT_ENERGY: f32 = 1.0e4; -/// Keep full horizontal detail while reducing the height used to find vertical seams. At a -/// 24 MP working size this cuts the dynamic-programming work by about four times without -/// quantising the output pixels or changing the requested output dimensions. +/// The energy protection `p` (`0..=1`) adds. NaN protects nothing. +#[inline] +fn protection(p: f32) -> f32 { + if p.is_nan() { 0.0 } else { p.clamp(0.0, 1.0) * PROTECT_ENERGY } +} + +/// Keep full horizontal detail while reducing the height used to find vertical seams. On a +/// 6000×4000 image this cuts the dynamic-programming work eight times for a width change (4000 +/// rows to 500) and twelve times for a height change, without quantising the output pixels or +/// changing the requested output dimensions. const SEAM_SEARCH_MAX_HEIGHT: usize = 512; const PROXY_SHRINK_MIN_HEIGHT: usize = 2_048; const PROXY_SHRINK_MIN_SEAMS: usize = 64; @@ -33,7 +43,7 @@ pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { } let row = |(y, out): (usize, &mut [f32])| { for (x, v) in out.iter_mut().enumerate() { - *v = energy_at(img, w, w, h, ch, x, y); + *v = energy_at(img, w, w, h, ch, x, y, |xx, _| xx); } }; #[cfg(not(target_arch = "wasm32"))] @@ -46,20 +56,25 @@ pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { e } -/// [`energy`] of one pixel of a `cw`-wide image whose rows are `stride` pixels apart. Shared by -/// the full map and the per-seam updates so both give bit-identical values. +/// [`energy`] of pixel `x` of row `y` of a `cw`-wide, `h`-tall image whose pixel `xx` of row `yy` +/// is column `col(xx, yy)` of `img` (`w` pixels per row). Shared by the full map and the carver's +/// per-seam updates, so both give bit-identical values. An energy that isn't a number is the +/// highest energy (`+inf`). +#[allow(clippy::too_many_arguments)] #[inline] -fn energy_at(img: &[f32], stride: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize) -> f32 { +fn energy_at(img: &[f32], w: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize, col: impl Fn(usize, usize) -> usize) -> f32 { let (xl, xr) = (x.saturating_sub(1), (x + 1).min(cw - 1)); let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1)); - let at = |xx: usize, yy: usize, c: usize| img[(yy * stride + xx) * ch + c]; + let at = |xx: usize, yy: usize| (yy * w + col(xx, yy)) * ch; + let (centre, right, left, down, up) = (at(x, y), at(xr, y), at(xl, y), at(x, yd), at(x, yu)); let mut s = 0.0; for c in 0..ch { - let v = at(x, y, c); - s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs(); - s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs(); + let v = img[centre + c]; + s += (img[right + c] - v).abs() + (v - img[left + c]).abs(); + s += (img[down + c] - v).abs() + (v - img[up + c]).abs(); } - s * 0.5 + let e = s * 0.5; + if e.is_nan() { f32::INFINITY } else { e } } /// The lowest-energy vertical seam: one x per row. @@ -67,57 +82,178 @@ pub fn find_seam(w: usize, h: usize, energy: &[f32]) -> Vec { if w == 0 || h == 0 || energy.len() < w * h { return vec![0; h]; } - Dp::default().seam(w, w, h, energy) + let off = vec![0; h]; + let rows = Rows { stride: w, cw: w, off: &off }; + let mut dp = Dp::default(); + dp.full(rows, energy); + dp.seam(rows) } -/// Scratch buffers for the seam search, reused across seams. +/// Where the `cw` pixels in use of each of a carver's rows are, in buffers whose rows are +/// `stride` apart: row y's are `off[y]..off[y] + cw` of its `stride` slots. +#[derive(Clone, Copy)] +struct Rows<'o> { + stride: usize, + cw: usize, + off: &'o [usize], +} + +impl Rows<'_> { + fn h(&self) -> usize { + self.off.len() + } + + /// Row y's pixels in use, as a range of its buffers. + #[inline] + fn of(&self, y: usize) -> std::ops::Range { + let start = y * self.stride + self.off[y]; + start..start + self.cw + } +} + +/// The seam search's dynamic programme over an energy map laid out as [`Rows`] (the carver's own +/// layout, so a removal shifts it like the other buffers): for every pixel, the cost of the +/// cheapest 8-connected path down to it from the top row. [`Carver`] keeps it across seams and +/// recomputes only what each removal changed ([`Dp::update`]). #[derive(Default)] struct Dp { + /// Cost of the cheapest path from the top row to each pixel. cost: Vec, - next: Vec, - /// Per pixel, where the cheapest path came from: 0 = up-left, 1 = up, 2 = up-right. - from: Vec, +} + +/// Pixel `x` of a `cw`-wide row with energy `e`, given the path costs of the row above (`up`): +/// its path cost and where that path came from (0 = up-left, 1 = up, 2 = up-right). Ties go to +/// the pixel straight up, then up-left, then up-right. The cost doesn't depend on the tie-break +/// and the direction doesn't depend on `e`, so the seam can be traced back from the costs alone. +/// [`row_costs`] uses it at the ends of a row and repeats its selection in between, and +/// [`Dp::seam`] traces seams with it, so every way of filling the map gives the same bits (costs +/// are never NaN: see [`energy_at`] and [`protection`]). +#[inline] +fn cell(up: &[f32], cw: usize, x: usize, e: f32) -> (f32, u8) { + let (b, d) = if x == 0 { + if cw > 1 && up[1] < up[0] { (up[1], 2) } else { (up[0], 1) } + } else if x + 1 == cw { + if up[x - 1] < up[x] { (up[x - 1], 0) } else { (up[x], 1) } + } else { + let (b, d) = if up[x - 1] < up[x] { (up[x - 1], 0) } else { (up[x], 1) }; + if up[x + 1] < b { (up[x + 1], 2) } else { (b, d) } + }; + (b + e, d) +} + +/// Path costs of pixels `lo..lo + out.len()` of a `cw`-wide row with energies `e` (the whole +/// row's), from the path costs of the row above (`up`), into `out`. [`cell`] at the ends of the +/// row; in between, the same selection without edge tests or directions, so it vectorises. +#[inline] +fn row_costs(up: &[f32], e: &[f32], cw: usize, lo: usize, out: &mut [f32]) { + let Some(last) = (lo + out.len()).checked_sub(1) else { return }; + let (mut a, mut b) = (lo, last); + if a == 0 { + out[0] = cell(up, cw, 0, e[0]).0; + a = 1; + } + if b + 1 == cw && b >= a { + out[b - lo] = cell(up, cw, b, e[b]).0; + b -= 1; + } + if a > b { + return; + } + // Every pixel in a..=b has all three parents: up-left, up and up-right. + let parents = up[a - 1..b].iter().zip(&up[a..=b]).zip(&up[a + 1..=b + 1]); + for ((n, &ev), ((&l, &u), &r)) in out[a - lo..=b - lo].iter_mut().zip(&e[a..=b]).zip(parents) { + let m = if l < u { l } else { u }; + let m = if r < m { r } else { m }; + *n = m + ev; + } } impl Dp { - /// The lowest-energy seam of a `cw`-wide, `h`-tall energy map with rows `stride` apart. - /// Ties go to the pixel straight up, then up-left, then up-right; at the bottom, to the - /// leftmost end. - fn seam(&mut self, stride: usize, cw: usize, h: usize, energy: &[f32]) -> Vec { - self.cost.clear(); - self.cost.extend_from_slice(&energy[..cw]); - self.next.resize(cw, 0.0); - self.from.resize(cw * h, 0); - for y in 1..h { - let (cost, next) = (&self.cost[..cw], &mut self.next[..cw]); - let e = &energy[y * stride..y * stride + cw]; - let from = &mut self.from[y * cw..(y + 1) * cw]; - if cw == 1 { - (next[0], from[0]) = (cost[0] + e[0], 1); - } else { - let (b, d) = if cost[1] < cost[0] { (cost[1], 2) } else { (cost[0], 1) }; - (next[0], from[0]) = (b + e[0], d); - // The interior has all three parents: no edge tests in the hot loop. - let inner = next[1..cw - 1].iter_mut().zip(&mut from[1..cw - 1]).zip(&e[1..cw - 1]).zip(cost.windows(3)); - for (((n, f), &ev), c) in inner { - let (b, d) = if c[0] < c[1] { (c[0], 0) } else { (c[1], 1) }; - let (b, d) = if c[2] < b { (c[2], 2) } else { (b, d) }; - (*n, *f) = (b + ev, d); - } - let (b, d) = if cost[cw - 2] < cost[cw - 1] { (cost[cw - 2], 0) } else { (cost[cw - 1], 1) }; - (next[cw - 1], from[cw - 1]) = (b + e[cw - 1], d); - } - std::mem::swap(&mut self.cost, &mut self.next); + /// Fill the map for the pixels in use of every row of `energy`. + fn full(&mut self, rows: Rows, energy: &[f32]) { + let (cw, h) = (rows.cw, rows.h()); + self.cost.resize(rows.stride * h, 0.0); + if cw == 0 || h == 0 { + return; } - let cost = &self.cost[..cw]; - let mut x = (0..cw).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0); + let first = rows.of(0); + self.cost[first.clone()].copy_from_slice(&energy[first]); + for y in 1..h { + let (above, rest) = self.cost.split_at_mut(y * rows.stride); + let (r, base) = (rows.of(y), y * rows.stride); + row_costs(&above[rows.of(y - 1)], &energy[r.clone()], cw, 0, &mut rest[r.start - base..r.end - base]); + } + } + + /// Bring the map up to date after `seam` (one x per row, counted before the removal) was + /// removed from every row of the map and of `energy`, leaving `rows.cw` pixels per row. + /// + /// A pixel's path cost depends only on its energy and on the costs of its parents in the row + /// above. Where a pixel and all its parents are on the same side of the seam in their rows, + /// the removal moved them together; that leaves, in row y, only the pixels from one left of + /// min(seam[y-1], seam[y]) to max(seam[y-1], seam[y]) with new parents. The energy changed + /// only where [`Carver::update_energy`] recomputed it: from one left of the leftmost of + /// `seam[y-1..=y+1]` to the rightmost, which covers those pixels too. Every other pixel needs + /// recomputing only if a parent's cost changed, so the work follows the changed costs down + /// the map and narrows wherever they settle, instead of covering the whole map. Recomputed + /// pixels go through [`row_costs`], like a [`Dp::full`] pass's, so the map matches one over + /// the same energy bit for bit. + fn update(&mut self, rows: Rows, energy: &[f32], seam: &[usize]) { + let (cw, h) = (rows.cw, rows.h()); + if cw == 0 || h == 0 || seam.len() < h { + return; + } + // Pixels of the row above whose cost changed: `lo..=hi`, or none when `lo > hi`. + let (mut changed_lo, mut changed_hi) = (usize::MAX, 0); + let mut fresh = Vec::new(); + for y in 0..h { + let near = &seam[y.saturating_sub(1)..(y + 2).min(h)]; + let mut lo = near.iter().min().map_or(0, |&s| s.saturating_sub(1)); + let mut hi = near.iter().max().map_or(0, |&s| s.min(cw - 1)); + if changed_lo <= changed_hi { + lo = lo.min(changed_lo.saturating_sub(1)); + hi = hi.max((changed_hi + 1).min(cw - 1)); + } + let (above, rest) = self.cost.split_at_mut(y * rows.stride); + let (r, base) = (rows.of(y), y * rows.stride); + let e = &energy[r.clone()]; + if fresh.len() < hi + 1 - lo { + fresh.resize(hi + 1 - lo, 0.0); + } + let fresh = &mut fresh[..hi + 1 - lo]; + match y.checked_sub(1) { + Some(u) => row_costs(&above[rows.of(u)], e, cw, lo, fresh), + None => fresh.copy_from_slice(&e[lo..=hi]), + } + // Store only what changed, and remember where that was for the row below. + let row = &mut rest[r.start - base + lo..r.start - base + hi + 1]; + let differs = |(a, b): (&f32, &f32)| a.to_bits() != b.to_bits(); + (changed_lo, changed_hi) = (usize::MAX, 0); + if let Some(first) = fresh.iter().zip(row.iter()).position(differs) { + let last = fresh.iter().zip(row.iter()).rposition(differs).unwrap_or(first); + row[first..=last].copy_from_slice(&fresh[first..=last]); + (changed_lo, changed_hi) = (lo + first, lo + last); + } + } + } + + /// The lowest-energy seam of the map: one x per row. At the bottom, ties go to the leftmost + /// end; going up, [`cell`] tells where each pixel's path came from. + fn seam(&self, rows: Rows) -> Vec { + let (cw, h) = (rows.cw, rows.h()); let mut seam = vec![0usize; h]; + if cw == 0 || h == 0 { + return seam; + } + let last = &self.cost[rows.of(h - 1)]; + let mut x = (0..cw).min_by(|a, b| last[*a].total_cmp(&last[*b])).unwrap_or(0); for y in (0..h).rev() { seam[y] = x; if y > 0 { - x = match self.from[y * cw + x] { - 0 => x - 1, - 2 => x + 1, + // `cell` never points past either end of the row; saturate rather than trust it. + x = match cell(&self.cost[rows.of(y - 1)], cw, x, 0.0).1 { + 0 => x.saturating_sub(1), + 2 => (x + 1).min(cw - 1), _ => x, }; } @@ -126,121 +262,223 @@ impl Dp { } } -/// Shrinks an image one seam at a time in place. Rows keep their original stride, so removing a -/// seam shifts only the rest of each row (in parallel), and the energy map is updated only next -/// to the removed seam instead of being recomputed: the gradient of a pixel changes only when a -/// neighbour does, which happens within a pixel of the seam in its own row and the rows around it. -struct Carver { - stride: usize, - cw: usize, +/// Shrinks an image one seam at a time without moving its pixels: the carver keeps which source +/// column each remaining pixel is (`idx`) and reads pixels through it. A removal shifts, in each +/// row, whichever side of the seam is shorter in the three per-pixel buffers (in parallel over +/// rows), and nothing is recomputed in full: the gradient of a pixel changes only when a +/// neighbour does, which happens within a pixel of the seam in its own row and the rows around +/// it, and the seam search's path costs are updated from there down ([`Dp::update`]). +struct Carver<'a> { + /// The image, `w × h × ch`, and the protection, one value per pixel: as given, never moved. + src: &'a [f32], + prot: Option<&'a [f32]>, + w: usize, h: usize, ch: usize, - img: Vec, - prot: Option>, + /// Pixels left per row. + cw: usize, + /// Where each row's first remaining pixel is in the per-pixel buffers (see [`Rows`]). + off: Vec, + /// Source column of each remaining pixel (rows `w` apart; layer areas are at most i32::MAX + /// wide, so `u32` holds any column). + idx: Vec, /// Energy plus protection, as the seam search sees it. e: Vec, - /// Original column of each pixel, when the caller needs to know which ones were removed. - idx: Option>, + /// The seam search over `e`, kept up to date. dp: Dp, } -impl Carver { - fn new(w: usize, h: usize, ch: usize, img: Vec, prot: Option>, track: bool) -> Self { - let mut e = energy(w, h, ch, &img); - if let Some(p) = &prot { - for (e, p) in e.iter_mut().zip(p) { - *e += p.clamp(0.0, 1.0) * PROTECT_ENERGY; +impl<'a> Carver<'a> { + fn new(w: usize, h: usize, ch: usize, src: &'a [f32], prot: Option<&'a [f32]>) -> Self { + let mut e = energy(w, h, ch, src); + if let Some(p) = prot { + for (e, &p) in e.iter_mut().zip(p) { + *e += protection(p); } } - let idx = track.then(|| (0..h).flat_map(|_| 0..w).collect()); - Carver { stride: w, cw: w, h, ch, img, prot, e, idx, dp: Dp::default() } + let mut idx = Vec::with_capacity(w * h); + for _ in 0..h { + idx.extend((0..w).map(|x| u32::try_from(x).unwrap_or(u32::MAX))); + } + let off = vec![0; h]; + let mut dp = Dp::default(); + dp.full(Rows { stride: w, cw: w, off: &off }, &e); + Carver { src, prot, w, h, ch, cw: w, off, idx, e, dp } } - /// Remove the lowest-energy seam. Returns, per row, the removed column (the original column - /// when tracking). + /// Remove the lowest-energy seam. Returns, per row, the source column removed. fn remove_seam(&mut self) -> Vec { - let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch); - let seam = self.dp.seam(stride, cw, h, &self.e); - let removed = match &self.idx { - Some(idx) => seam.iter().enumerate().map(|(y, &x)| idx[y * stride + x]).collect(), - None => seam.clone(), - }; - shift_rows(&mut self.img, stride * ch, &seam, ch, cw); - shift_rows(&mut self.e, stride, &seam, 1, cw); - if let Some(p) = &mut self.prot { - shift_rows(p, stride, &seam, 1, cw); - } - if let Some(idx) = &mut self.idx { - shift_rows(idx, stride, &seam, 1, cw); + if self.cw == 0 { + return Vec::new(); } + let rows = Rows { stride: self.w, cw: self.cw, off: &self.off }; + let seam = self.dp.seam(rows); + let removed = seam.iter().enumerate().map(|(y, &x)| self.idx[rows.of(y).start + x] as usize).collect(); + self.shift(&seam); self.cw -= 1; self.update_energy(&seam); + self.dp.update(Rows { stride: self.w, cw: self.cw, off: &self.off }, &self.e, &seam); removed } + /// Remove `seam[y]` from each row of the per-pixel buffers. + fn shift(&mut self, seam: &[usize]) { + let (w, cw) = (self.w, self.cw); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + self.e + .par_chunks_mut(w) + .zip(self.idx.par_chunks_mut(w)) + .zip(self.dp.cost.par_chunks_mut(w)) + .zip(self.off.par_iter_mut()) + .zip(seam.par_iter()) + .for_each(|((((e, idx), cost), off), &x)| shift_row(e, idx, cost, off, x, cw)); + } + #[cfg(target_arch = "wasm32")] + self.e + .chunks_mut(w) + .zip(self.idx.chunks_mut(w)) + .zip(self.dp.cost.chunks_mut(w)) + .zip(self.off.iter_mut()) + .zip(seam) + .for_each(|((((e, idx), cost), off), &x)| shift_row(e, idx, cost, off, x, cw)); + } + /// Recompute the energy of the pixels whose neighbours changed when `seam` was removed: in /// row y, from one left of the leftmost of the seam's x in rows y-1..=y+1 to the rightmost. fn update_energy(&mut self, seam: &[usize]) { - let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch); + let (w, cw, h, ch) = (self.w, self.cw, self.h, self.ch); if cw == 0 { return; } - let (img, prot) = (&self.img, self.prot.as_deref()); + let (src, prot, off, idx) = (self.src, self.prot, &self.off, &self.idx); + let col = |xx: usize, yy: usize| idx[yy * w + off[yy] + xx] as usize; let row = |(y, e): (usize, &mut [f32])| { let near = &seam[y.saturating_sub(1)..(y + 2).min(h)]; let lo = near.iter().min().map_or(0, |&s| s.saturating_sub(1)); let hi = near.iter().max().map_or(0, |&s| s.min(cw - 1)); for x in lo..=hi { - let mut v = energy_at(img, stride, cw, h, ch, x, y); + let mut v = energy_at(src, w, cw, h, ch, x, y, col); if let Some(p) = prot { - v += p[y * stride + x].clamp(0.0, 1.0) * PROTECT_ENERGY; + v += protection(p[y * w + col(x, y)]); } - e[x] = v; + e[off[y] + x] = v; + } + }; + // A few pixels per row (seams move at most one column per row), but each waits on reads + // from three rows of the source: rows in parallel overlap the waits. + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + self.e.par_chunks_mut(w).enumerate().for_each(row); + } + #[cfg(target_arch = "wasm32")] + self.e.chunks_mut(w).enumerate().for_each(row); + } + + /// The carved pixels, `cw × h × ch`, rows packed. + fn into_pixels(self) -> Vec { + let (w, cw, ch) = (self.w, self.cw, self.ch); + let mut out = vec![0.0f32; cw * self.h * ch]; + if cw == 0 { + return out; + } + let (src, off, idx) = (self.src, &self.off, &self.idx); + let row = |(y, out): (usize, &mut [f32])| { + let cols = &idx[y * w + off[y]..y * w + off[y] + cw]; + for (px, &x) in out.chunks_exact_mut(ch).zip(cols) { + let s = (y * w + x as usize) * ch; + px.copy_from_slice(&src[s..s + ch]); } }; #[cfg(not(target_arch = "wasm32"))] { use rayon::prelude::*; - self.e.par_chunks_mut(stride).enumerate().for_each(row); + out.par_chunks_mut(cw * ch).enumerate().for_each(row); } #[cfg(target_arch = "wasm32")] - self.e.chunks_mut(stride).enumerate().for_each(row); - } - - /// The carved pixels, `cw × h × ch`, rows packed. - fn into_pixels(self) -> Vec { - let (row, used) = (self.stride * self.ch, self.cw * self.ch); - if row == used { - return self.img; - } - self.img.chunks(row).flat_map(|r| &r[..used]).copied().collect() + out.chunks_mut(cw * ch).enumerate().for_each(row); + out } } -/// Remove pixel `seam[y]` (of `n` values) from each `row_len`-long row whose first `cw` pixels -/// are in use, by shifting the rest of the row left. -fn shift_rows(buf: &mut [T], row_len: usize, seam: &[usize], n: usize, cw: usize) { - let shift = |(r, &x): (&mut [T], &usize)| r.copy_within((x + 1) * n..cw * n, x * n); - #[cfg(not(target_arch = "wasm32"))] - { - use rayon::prelude::*; - buf.par_chunks_mut(row_len).zip(seam.par_iter()).for_each(shift); +/// Remove pixel `x` of the `cw` in use, from `off` on, of one row of a carver's per-pixel +/// buffers, moving whichever side of it holds fewer pixels (the row then starts one later when +/// that is the left side). +fn shift_row(e: &mut [f32], idx: &mut [u32], cost: &mut [f32], off: &mut usize, x: usize, cw: usize) { + let o = *off; + if x < cw - 1 - x { + e.copy_within(o..o + x, o + 1); + idx.copy_within(o..o + x, o + 1); + cost.copy_within(o..o + x, o + 1); + *off = o + 1; + } else { + e.copy_within(o + x + 1..o + cw, o + x); + idx.copy_within(o + x + 1..o + cw, o + x); + cost.copy_within(o + x + 1..o + cw, o + x); } - #[cfg(target_arch = "wasm32")] - buf.chunks_mut(row_len).zip(seam).for_each(shift); } fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { let mut out = vec![0.0f32; img.len()]; - for y in 0..h { - for x in 0..w { - let (s, d) = ((y * w + x) * ch, (x * h + y) * ch); - out[d..d + ch].copy_from_slice(&img[s..s + ch]); - } + if w == 0 || h == 0 || ch == 0 { + return out; } + // Output rows (source columns) in blocks, so each source cache line serves several of them. + const BLOCK: usize = 16; + let block = |(b, out): (usize, &mut [f32])| { + let x0 = b * BLOCK; + let n = out.len() / (h * ch); + for y in 0..h { + for i in 0..n { + let (s, d) = ((y * w + x0 + i) * ch, (i * h + y) * ch); + out[d..d + ch].copy_from_slice(&img[s..s + ch]); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(BLOCK * h * ch).enumerate().for_each(block); + } + #[cfg(target_arch = "wasm32")] + out.chunks_mut(BLOCK * h * ch).enumerate().for_each(block); out } +/// Each `step` rows of `src` (`h` rows of `row_len` values) averaged into one, the last group +/// possibly shorter: `h.div_ceil(step)` rows. +fn average_rows(src: &[f32], row_len: usize, h: usize, step: usize) -> Vec { + let mut out = vec![0.0; h.div_ceil(step) * row_len]; + if row_len == 0 { + return out; + } + let group = |(py, out_row): (usize, &mut [f32])| { + let y0 = py * step; + let y1 = (y0 + step).min(h); + let divisor = (y1 - y0) as f32; + for y in y0..y1 { + for (out, &sample) in out_row.iter_mut().zip(&src[y * row_len..(y + 1) * row_len]) { + *out += sample / divisor; + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_exact_mut(row_len).enumerate().for_each(group); + } + #[cfg(target_arch = "wasm32")] + out.chunks_exact_mut(row_len).enumerate().for_each(group); + out +} + +/// No pixels or channels to work with, or `img` holds fewer than `w × h × ch` values. +fn unusable(w: usize, h: usize, ch: usize, img: &[f32]) -> bool { + w == 0 || h == 0 || ch == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(ch)).is_none_or(|n| img.len() < n) +} + /// Change the width of `img` to `new_w` by removing or duplicating vertical seams. pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize) -> Vec { // Never cancelled; the fallback is unreachable. @@ -250,15 +488,23 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<& /// [`carve_width`] that checks `ctl` before every seam and reports progress per seam. pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result, Cancelled> { let new_w = new_w.max(1); - if new_w == w || w == 0 || h == 0 { + // Nothing to carve: no pixels or channels, or fewer values than the size says. + if unusable(w, h, ch, img) { return Ok(img.to_vec()); } + // Values past the size are not part of the image. + let img = img.get(..w * h * ch).unwrap_or(img); + if new_w == w { + return Ok(img.to_vec()); + } + // A protection map that doesn't cover the image protects nothing. + let protect = protect.filter(|p| p.len() >= w * h); let seams = new_w.abs_diff(w).max(1) as f32; if new_w < w { if h >= PROXY_SHRINK_MIN_HEIGHT && w - new_w >= PROXY_SHRINK_MIN_SEAMS { return carve_width_proxy_height(w, h, ch, img, protect, new_w, ctl); } - let mut carver = Carver::new(w, h, ch, img.to_vec(), protect.map(<[f32]>::to_vec), false); + let mut carver = Carver::new(w, h, ch, img, protect); while carver.cw > new_w { ctl.check()?; ctl.progress((w - carver.cw) as f32 / seams); @@ -272,7 +518,7 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt while cw < new_w { let k = (new_w - cw).min((cw / 2).max(1)); // Find k seams on a shrinking copy, mapping each back to original columns. - let mut carver = Carver::new(cw, h, ch, cur.clone(), prot.clone(), true); + let mut carver = Carver::new(cw, h, ch, &cur, prot.as_deref()); let mut dup: Vec> = vec![Vec::with_capacity(k); h]; for _ in 0..k { // A 1 px wide image's only seam is its one column: take it, so it is duplicated. @@ -329,37 +575,11 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: let step = h.div_ceil(SEAM_SEARCH_MAX_HEIGHT); let proxy_h = h.div_ceil(step); let row_len = w * ch; - let proxy_row_len = proxy_h * row_len; - let mut proxy = vec![0.0; proxy_row_len]; - for (py, out_row) in proxy.chunks_exact_mut(row_len).enumerate() { - let y0 = py * step; - let y1 = (y0 + step).min(h); - let divisor = (y1 - y0) as f32; - for y in y0..y1 { - let in_row = &img[y * row_len..(y + 1) * row_len]; - for (out, &sample) in out_row.iter_mut().zip(in_row) { - *out += sample / divisor; - } - } - } - let proxy_protect = protect.map(|mask| { - let mut reduced = vec![0.0; proxy_h * w]; - for (py, out_row) in reduced.chunks_exact_mut(w).enumerate() { - let y0 = py * step; - let y1 = (y0 + step).min(h); - let divisor = (y1 - y0) as f32; - for y in y0..y1 { - let in_row = &mask[y * w..(y + 1) * w]; - for (out, &sample) in out_row.iter_mut().zip(in_row) { - *out += sample / divisor; - } - } - } - reduced - }); + let proxy = average_rows(img, row_len, h, step); + let proxy_protect = protect.map(|mask| average_rows(mask, w, h, step)); let seam_count = w - new_w; - let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true); + let mut carver = Carver::new(w, proxy_h, ch, &proxy, proxy_protect.as_deref()); let mut removed = Vec::with_capacity(seam_count); while carver.cw > new_w { ctl.check()?; @@ -373,31 +593,44 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: columns.sort_unstable(); removed_by_row.push(columns); } - let mut out = Vec::with_capacity(new_w * h * ch); - let mut columns = vec![0; seam_count]; - for y in 0..h { + // Each full-resolution row removes the columns interpolated between its two proxy rows'. + let rebuild = |columns: &mut Vec, (y, out): (usize, &mut [f32])| -> Result<(), Cancelled> { ctl.check()?; let proxy_y = (y / step).min(proxy_h - 1); let next_proxy_y = (proxy_y + 1).min(proxy_h - 1); let blend = (y % step) as f32 / step as f32; let lower = &removed_by_row[proxy_y]; let upper = &removed_by_row[next_proxy_y]; + columns.clear(); let mut min_x = 0; - for (i, x) in columns.iter_mut().enumerate() { + for i in 0..seam_count { let interpolated = lower[i] as f32 + (upper[i] as f32 - lower[i] as f32) * blend; let max_x = w - (seam_count - i); - *x = (interpolated.round() as usize).clamp(min_x, max_x); + let x = (interpolated.round() as usize).clamp(min_x, max_x); + columns.push(x); min_x = x.saturating_add(1); } - let mut next = 0; + // The columns rise strictly within the row: copy the runs between them. let row = &img[y * row_len..(y + 1) * row_len]; - for (x, pixel) in row.chunks_exact(ch).enumerate() { - if columns.get(next) == Some(&x) { - next += 1; - } else { - out.extend_from_slice(pixel); - } + let (mut from, mut at) = (0, 0); + for &x in columns.iter() { + let run = (x - from) * ch; + out[at..at + run].copy_from_slice(&row[from * ch..x * ch]); + (from, at) = (x + 1, at + run); } + out[at..].copy_from_slice(&row[from * ch..]); + Ok(()) + }; + let mut out = vec![0.0f32; new_w * h * ch]; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_exact_mut(new_w * ch).enumerate().try_for_each_init(|| Vec::with_capacity(seam_count), rebuild)?; + } + #[cfg(target_arch = "wasm32")] + { + let mut columns = Vec::with_capacity(seam_count); + out.chunks_exact_mut(new_w * ch).enumerate().try_for_each(|r| rebuild(&mut columns, r))?; } ctl.progress(1.0); Ok(out) @@ -423,6 +656,11 @@ pub fn carve_with( ctl: &Interrupt, ) -> Result, Cancelled> { let (new_w, new_h) = (new_w.max(1), new_h.max(1)); + if unusable(w, h, ch, img) { + return Ok(img.to_vec()); + } + let img = img.get(..w * h * ch).unwrap_or(img); + let protect = protect.filter(|p| p.len() >= w * h); // Split progress between the passes by their seam counts. let (sw, sh) = (new_w.abs_diff(w) as f32, new_h.abs_diff(h) as f32); let split = if sw + sh > 0.0 { sw / (sw + sh) } else { 1.0 }; @@ -432,21 +670,40 @@ pub fn carve_with( if new_h == h { return Ok(wide); } - // Protection follows the width change by plain resampling (it only steers seams). - let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h))); + let prot_t = protect.map(|p| transpose(new_w, h, 1, &resample_protection(w, h, p, new_w))); let t = transpose(new_w, h, ch, &wide); let second = |f: f32| ctl.progress(split + f * (1.0 - split)); let tall = carve_width_with(h, new_w, ch, &t, prot_t.as_deref(), new_h, &Interrupt::new(&cancel, &second))?; Ok(transpose(new_h, new_w, ch, &tall)) } +/// The protection map `p` (`w × h`) for the height pass, after the width changed to `new_w`: +/// plainly resampled (it only steers seams). Where values out of `0..=1` resample to NaN +/// (infinities, or neighbours whose difference overflows), the map clamped to `0..=1` (NaN to 0) +/// resampled instead, so an infinite protection still protects. +fn resample_protection(w: usize, h: usize, p: &[f32], new_w: usize) -> Vec { + let mut out = resize_bilinear(w, h, 1, p, new_w, h); + if out.iter().any(|v| v.is_nan()) { + let clamped: Vec = p.iter().map(|&v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }).collect(); + for (v, c) in out.iter_mut().zip(resize_bilinear(w, h, 1, &clamped, new_w, h)) { + if v.is_nan() { + *v = c; + } + } + } + out +} + /// Plain bilinear resize (the non-content-aware part of the Amount blend). pub fn resize_bilinear(w: usize, h: usize, ch: usize, img: &[f32], nw: usize, nh: usize) -> Vec { - let mut out = vec![0.0f32; nw * nh * ch]; - if w == 0 || h == 0 { + let Some(len) = nw.checked_mul(nh).and_then(|n| n.checked_mul(ch)) else { + return Vec::new(); + }; + let mut out = vec![0.0f32; len]; + if len == 0 || unusable(w, h, ch, img) { return out; } - for y in 0..nh { + let row = |(y, out): (usize, &mut [f32])| { let fy = ((y as f32 + 0.5) * h as f32 / nh as f32 - 0.5).clamp(0.0, (h - 1) as f32); let (y0, ty) = (fy.floor() as usize, fy.fract()); let y1 = (y0 + 1).min(h - 1); @@ -458,10 +715,17 @@ pub fn resize_bilinear(w: usize, h: usize, ch: usize, img: &[f32], nw: usize, nh let p = |xx: usize, yy: usize| img[(yy * w + xx) * ch + c]; let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx; let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx; - out[(y * nw + x) * ch + c] = top + (bot - top) * ty; + out[x * ch + c] = top + (bot - top) * ty; } } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(nw * ch).enumerate().for_each(row); } + #[cfg(target_arch = "wasm32")] + out.chunks_mut(nw * ch).enumerate().for_each(row); out } @@ -490,7 +754,7 @@ mod tests { /// The straightforward implementation the per-seam carver replaced (full energy map, fresh /// seam search and a new buffer per seam), kept as the oracle for `carver_matches_reference`. mod reference { - use super::super::{PROTECT_ENERGY, resize_bilinear, transpose}; + use super::super::{protection, resample_protection, transpose}; pub(super) fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { let mut e = vec![0.0f32; w * h]; @@ -505,7 +769,8 @@ mod tests { s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs(); s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs(); } - e[y * w + x] = s * 0.5; + let v = s * 0.5; + e[y * w + x] = if v.is_nan() { f32::INFINITY } else { v }; } } e @@ -558,8 +823,8 @@ mod tests { } let protect_e = |e: &mut [f32], p: Option<&Vec>| { if let Some(p) = p { - for (e, p) in e.iter_mut().zip(p) { - *e += p.clamp(0.0, 1.0) * PROTECT_ENERGY; + for (e, &p) in e.iter_mut().zip(p) { + *e += protection(p); } } }; @@ -642,7 +907,7 @@ mod tests { if new_h == h { return wide; } - let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h))); + let prot_t = protect.map(|p| transpose(new_w, h, 1, &resample_protection(w, h, p, new_w))); let t = transpose(new_w, h, ch, &wide); let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h); transpose(new_h, new_w, ch, &tall) @@ -683,6 +948,161 @@ mod tests { } } + /// After every removal, the carver's incrementally updated seam search matches a full pass + /// over its current energy bit for bit (path costs and the seam traced from them), on images + /// where seams run through the edges, through flat areas full of ties and through protection. + /// Its energy matches a fresh map of the pixels left, and so do the pixels. + #[test] + fn incremental_dp_matches_full_dp() { + let mut ramp = vec![0.0f32; 120 * 90]; + for (i, v) in ramp.iter_mut().enumerate() { + let (x, y) = ((i % 120) as f32, (i / 120) as f32); + *v = ((x * 0.07).sin() * (y * 0.05).cos() * 0.5 + 0.5).clamp(0.0, 1.0); + } + // NaN and infinite samples and NaN protection: infinite energies, never NaN costs. + let mut odd = noise(40 * 30 * 3, 255, 11); + odd.iter_mut().step_by(13).for_each(|v| *v = f32::NAN); + odd.iter_mut().skip(5).step_by(17).for_each(|v| *v = f32::INFINITY); + let mut odd_prot = noise(40 * 30, 3, 12); + odd_prot.iter_mut().step_by(7).for_each(|v| *v = -f32::NAN); + // (w, h, channels, image, protection, seams to remove) + type Case = (usize, usize, usize, Vec, Option>, usize); + let cases: Vec = vec![ + (120, 90, 1, ramp, None, 40), + (97, 64, 3, noise(97 * 64 * 3, 2, 5), None, 90), + (80, 70, 4, noise(80 * 70 * 4, 1000, 6), Some(noise(80 * 70, 3, 7)), 60), + (64, 50, 3, bar(64, 50), None, 63), + (33, 1, 1, noise(33, 5, 8), None, 32), + (2, 40, 3, noise(2 * 40 * 3, 9, 9), None, 2), + (50, 40, 1, vec![0.5; 50 * 40], Some(noise(50 * 40, 2, 10)), 49), + (40, 30, 3, odd, Some(odd_prot), 25), + ]; + let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::>(); + for (case, (w, h, ch, img, prot, seams)) in cases.into_iter().enumerate() { + let mut c = Carver::new(w, h, ch, &img, prot.as_deref()); + for step in 0..=seams { + let rows = Rows { stride: c.w, cw: c.cw, off: &c.off }; + let mut fresh = Dp::default(); + fresh.full(rows, &c.e); + for y in 0..h { + assert_eq!(bits(&c.dp.cost[rows.of(y)]), bits(&fresh.cost[rows.of(y)]), "case {case} step {step} row {y}: costs"); + } + assert_eq!(c.dp.seam(rows), fresh.seam(rows), "case {case} step {step}: seam"); + // The pixels left, packed, and their energy (plus protection) from scratch. + let left: Vec = (0..h) + .flat_map(|y| c.idx[rows.of(y)].iter().flat_map(move |&x| (0..ch).map(move |k| (y * w + x as usize) * ch + k))) + .map(|i| img[i]) + .collect(); + let mut want = energy(c.cw, h, ch, &left); + if let Some(p) = &prot { + let kept = (0..h).flat_map(|y| c.idx[rows.of(y)].iter().map(move |&x| y * w + x as usize)); + for (e, i) in want.iter_mut().zip(kept) { + *e += protection(p[i]); + } + } + let have: Vec = (0..h).flat_map(|y| c.e[rows.of(y)].to_vec()).collect(); + assert_eq!(bits(&have), bits(&want), "case {case} step {step}: energy"); + if step == seams { + assert_eq!(bits(&c.into_pixels()), bits(&left), "case {case}: pixels"); + break; + } + c.remove_seam(); + } + } + } + + /// Hostile but well-formed shapes return early instead of panicking: no channels, empty + /// sizes (also through the height pass), and images or protection maps shorter than their + /// size says (a short map protects nothing). + #[test] + fn degenerate_inputs_do_not_panic() { + assert!(carve(3, 3, 0, &[], None, 2, 2).is_empty()); + assert!(carve(3, 3, 0, &[], None, 5, 5).is_empty()); + assert!(transpose(3, 3, 0, &[]).is_empty()); + assert!(resize_bilinear(3, 3, 0, &[], 2, 2).is_empty()); + assert!(resize_bilinear(3, 3, 1, &[0.5; 9], 0, 2).is_empty()); + assert!(resize_bilinear(3, 3, 1, &[0.5; 9], 2, 0).is_empty()); + assert_eq!(resize_bilinear(3, 3, 1, &[0.5; 4], 2, 2), vec![0.0; 4]); + assert!(carve(0, 5, 3, &[], None, 3, 7).is_empty()); + assert!(carve(0, 5, 3, &[], None, 3, 2).is_empty()); + let img = noise(10 * 5, 7, 3); + assert_eq!(carve(10, 5, 1, &img, Some(&[0.0; 3]), 4, 5), carve(10, 5, 1, &img, None, 4, 5)); + assert_eq!(carve(10, 5, 1, &img, Some(&[0.0; 3]), 4, 3), carve(10, 5, 1, &img, None, 4, 3)); + assert_eq!(carve(10, 5, 3, &[0.5; 20], None, 4, 5), vec![0.5; 20]); + assert_eq!(carve_width(usize::MAX, 2, 4, &[0.5; 8], None, 1), vec![0.5; 8]); + // Values past the size are ignored, through both passes. + let long = noise(20, 7, 4); + assert_eq!(carve(5, 3, 1, &long, None, 3, 3), carve(5, 3, 1, &long[..15], None, 3, 3)); + assert_eq!(carve(5, 3, 1, &long, None, 5, 2), carve(5, 3, 1, &long[..15], None, 5, 2)); + } + + /// Infinite samples, and finite ones whose differences overflow, give energies that aren't + /// numbers; those are the highest energy (pinned deliberately: the code before NaN-free costs + /// fed NaN to the seam search and chose differently). + #[test] + fn infinite_samples_carve_deterministically() { + let inf = f32::INFINITY; + assert_eq!(carve(2, 1, 1, &[inf, 0.5], None, 1, 1), [0.5]); + assert_eq!(carve(2, 1, 1, &[f32::MAX, f32::MAX], None, 6, 1), [f32::MAX, inf, inf, inf, inf, f32::MAX]); + let mut img = noise(30 * 20 * 3, 255, 41); + img.iter_mut().step_by(11).for_each(|v| *v = inf); + img.iter_mut().skip(4).step_by(23).for_each(|v| *v = -inf); + let out = carve(30, 20, 3, &img, None, 21, 14); + assert_eq!(out.len(), 21 * 14 * 3); + assert_eq!(out, carve(30, 20, 3, &img, None, 21, 14)); + } + + /// Infinite protection protects like full protection in the height pass too, where the map + /// is resampled (and an infinity alone would resample to NaN). + #[test] + fn infinite_protection_protects_in_both_passes() { + let (w, h) = (6, 6); + let img: Vec = (0..w * h).map(|i| (i * 7 % 5) as f32 / 4.0).collect(); + let row = |v: f32| (0..w * h).map(|i| if i / w == 2 { v } else { 0.0 }).collect::>(); + let (full, inf) = (row(1.0), row(f32::INFINITY)); + for (nw, nh) in [(6, 3), (4, 3), (3, 6)] { + assert_eq!(carve(w, h, 1, &img, Some(&inf), nw, nh), carve(w, h, 1, &img, Some(&full), nw, nh), "{nw}x{nh}"); + } + let kept = carve(w, h, 1, &img, Some(&inf), 6, 3); + assert!(kept.chunks(6).any(|r| r == &img[12..18]), "the protected row survives"); + } + + /// A cancel that arrives after the seam search, during the tall-image rebuild, still stops + /// the carve. + #[test] + fn proxy_rebuild_observes_cancellation() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (w, h, ch, new_w) = (128, PROXY_SHRINK_MIN_HEIGHT, 3, 64); + let img = noise(w * h * ch, 7, 5); + let calls = AtomicUsize::new(0); + // One check per seam passes; the first one after the search cancels. + let cancel = || calls.fetch_add(1, Ordering::Relaxed) >= w - new_w; + assert_eq!(carve_width_with(w, h, ch, &img, None, new_w, &Interrupt::cancel_only(&cancel)), Err(Cancelled)); + assert!(calls.load(Ordering::Relaxed) > w - new_w, "the rebuild checked for cancellation"); + } + + /// NaN samples and NaN protection steer seams the same whatever their sign and payload + /// (which arithmetic does not preserve consistently across builds): the same pixels are kept. + #[test] + fn nan_payloads_do_not_steer_seams() { + let (w, h, ch) = (24, 18, 3); + let (base, prot_base) = (noise(w * h * ch, 255, 21), noise(w * h, 3, 22)); + let carve_with_nans = |pixel: f32, prot: f32| { + let (mut img, mut p) = (base.clone(), prot_base.clone()); + img.iter_mut().step_by(37).for_each(|v| *v = pixel); + p.iter_mut().step_by(29).for_each(|v| *v = prot); + carve(w, h, ch, &img, Some(&p), 15, 11) + }; + let canonical = |v: Vec| v.into_iter().map(|f| if f.is_nan() { u32::MAX } else { f.to_bits() }).collect::>(); + let nans = [f32::NAN, -f32::NAN, f32::from_bits(0x7fc0_1234), f32::from_bits(0xffc0_0001)]; + let want = canonical(carve_with_nans(nans[0], nans[0])); + for &a in &nans { + for &b in &nans { + assert_eq!(canonical(carve_with_nans(a, b)), want, "pixel NaN {:#x}, protection NaN {:#x}", a.to_bits(), b.to_bits()); + } + } + } + #[test] fn cancelled_carve_stops() { let (w, h) = (40, 20); @@ -852,6 +1272,35 @@ mod tests { 0x2157_9f86_27d6_b835, ]; + /// [`output_bits_are_pinned`] for the reduced-height seam search of tall images (rows at + /// least [`PROXY_SHRINK_MIN_HEIGHT`], at least [`PROXY_SHRINK_MIN_SEAMS`] seams): widths and, + /// through the transposed pass, heights, with and without protection, on tie-heavy and + /// fine-grained noise. + #[test] + fn tall_proxy_output_bits_are_pinned() { + let cases: [(usize, usize, usize, u32, bool, usize, usize); 5] = [ + (96, PROXY_SHRINK_MIN_HEIGHT, 3, 2, false, 30, PROXY_SHRINK_MIN_HEIGHT), + (130, 2_100, 4, 255, true, 64, 2_100), + (80, 2_600, 1, 65_536, false, 16, 2_600), + (PROXY_SHRINK_MIN_HEIGHT, 90, 3, 5, false, PROXY_SHRINK_MIN_HEIGHT, 20), + (2_050, 100, 4, 1000, true, 2_050, 36), + ]; + let got: Vec = cases + .iter() + .enumerate() + .map(|(i, &(w, h, ch, levels, prot, nw, nh))| { + let img = noise(w * h * ch, levels, 31 + i as u32); + let protect = prot.then(|| noise(w * h, 3, 131 + i as u32)); + let out = carve(w, h, ch, &img, protect.as_deref(), nw, nh); + assert_eq!(out.len(), nw * nh * ch); + fnv(&out) + }) + .collect(); + assert_eq!(got, TALL_PINNED); + } + + const TALL_PINNED: [u64; 5] = [0x5657_83ac_cb26_56a5, 0x81a6_b5f7_e299_fdfb, 0xcdf2_9df9_9b83_def5, 0xa713_544a_4dcc_d8d8, 0x3557_de9f_ffb0_881e]; + #[test] fn one_pixel_lines_enlarge_by_repeating() { // Width 1 has no seam to spare: its one column is repeated. Height 1 likewise (through diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index e1c1d89de0..95ed668e68 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -141,11 +141,15 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000; /// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the /// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both /// by the distance to the seed's running mean and by the local edge step), so it stops at the -/// object's outline. A region that grows over most of the canvas is the background: nothing is -/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable +/// object's outline. A region covering most of the canvas boundary is treated as background. +/// Otherwise GrabCut refines it inside its padded bounding box (the grown area probable /// foreground, the cursor definite foreground, outside the box definite background), the part /// connected to the cursor is kept and the boundary is re-cut at full resolution, as the /// rectangle mode does. +/// +/// When the whole-canvas pass finds nothing in a large document (an object only a few working +/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of +/// the same working size around the point, down to full resolution. pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option { if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() { return None; @@ -154,7 +158,30 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u if !canvas.contains(px, py) { return None; } - let step = super::scale_for(canvas, work_px.max(1024)); + let work_px = work_px.max(1024); + let mut step = super::scale_for(canvas, work_px); + if let Some(r) = object_in(sampler, canvas, px, py, step) { + return Some(r); + } + // Side (working pixels) of the square windows of the finer passes. + let side = (work_px as f64).sqrt() as usize; + while step > 1 { + step = (step / 4).max(1); + let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32; + let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas); + if window.width() < 4 || window.height() < 4 { + continue; + } + if let Some(r) = object_in(sampler, window, px, py, step) { + return Some(r); + } + } + None +} + +/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`; +/// `(px, py)` lies inside `canvas`. +fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option { let img = sampler.rgb_scaled(canvas, step); let (w, h) = (img.w, img.h); if w < 2 || h < 2 { @@ -166,8 +193,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u let sy = (((py - canvas.y0) as usize) / step).min(h - 1); let grown = grow_similar(&lab, w, h, sx, sy); let count = grown.iter().filter(|g| **g).count(); - // Background (or a flat image): no single object under the cursor. - if count * 10 > w * h * 6 || count < 4 { + let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count(); + // shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788). + if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 { return None; } let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index b30ce909b7..f586453db4 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -524,23 +524,24 @@ fn components4(mask: &[bool], w: usize, h: usize) -> (Vec, Vec) { /// rule as the magic wand's anti-aliasing). pub fn antialias_u8(mask: &mut [u8], w: usize, h: usize) { let src = mask.to_vec(); - let at = |x: i32, y: i32, v: u8| if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 { v } else { src[y as usize * w + x as usize] }; + // A span whose 3×3 neighbourhood holds one value has no edge pixel, so it is only compared: + // most of a full-resolution mask is far from its boundary. 64 pixels keep the three row + // compares cheap per pixel, and a span that does reach the boundary costs few extra pixels. + const SPAN: usize = 64; + let uniform = |y: usize, x0: usize, x1: usize| { + let (a, b, v) = (x0.saturating_sub(1), (x1 + 1).min(w), src[y * w + x0]); + (y.saturating_sub(1)..(y + 2).min(h)).all(|ny| src[ny * w + a..ny * w + b].iter().all(|n| *n == v)) + }; let row = |y: usize, out: &mut [u8]| { - for (x, o) in out.iter_mut().enumerate() { - let v = src[y * w + x]; - let mut sum = 0u32; - let mut edge = false; - for dy in -1..=1 { - for dx in -1..=1 { - let n = at(x as i32 + dx, y as i32 + dy, v); - edge |= n != v; - sum += n as u32; - } + for (c, span) in out.chunks_mut(SPAN).enumerate() { + let x0 = c * SPAN; + if uniform(y, x0, x0 + span.len()) { + continue; } - if edge { - let avg = sum as f32 / (9.0 * 255.0); - let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg }; - *o = (f * 255.0).round() as u8; + for (i, o) in span.iter_mut().enumerate() { + if let Some(v) = edge_value(&src, w, h, x0 + i, y) { + *o = v; + } } } }; @@ -553,6 +554,27 @@ pub fn antialias_u8(mask: &mut [u8], w: usize, h: usize) { mask.chunks_mut(w).enumerate().for_each(|(y, r)| row(y, r)); } +/// The anti-aliased value of pixel `(x, y)` of `src` (`w` × `h`), or `None` when every neighbour +/// in its 3×3 neighbourhood has its value (neighbours outside the mask count as the pixel itself). +fn edge_value(src: &[u8], w: usize, h: usize, x: usize, y: usize) -> Option { + let v = src[y * w + x]; + let mut sum = 0u32; + let mut edge = false; + for dy in -1..=1 { + for dx in -1..=1 { + let (nx, ny) = (x as i32 + dx, y as i32 + dy); + let n = if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { v } else { src[ny as usize * w + nx as usize] }; + edge |= n != v; + sum += n as u32; + } + } + edge.then(|| { + let avg = sum as f32 / (9.0 * 255.0); + let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg }; + (f * 255.0).round() as u8 + }) +} + /// Shrinks a region's box to its non-zero pixels (`None` if empty). pub fn trim_region(r: Region) -> Option { let w = r.bbox.width() as usize; diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index e060913c9b..135c47b223 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -751,6 +751,38 @@ fn object_at_finds_the_disc_under_the_cursor() { assert!(v > 0.9, "IoU {v}"); } +#[test] +fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() { + let (w, h) = (400, 300); + for margin in [30, 20, 8] { + let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin; + let img = RgbImage::from_fn(w, h, |x, y| { + let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0; + if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] } + }); + let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let sampler = ImageSampler { img: &img, origin: (-40, 25) }; + let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25); + let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX) + .unwrap_or_else(|| panic!("missing large foreground with margin {margin}")); + let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask }; + assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}"); + assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}"); + } +} + +#[test] +fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() { + let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 400, 300); + let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap(); + assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9); + assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1); + let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]); + assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + #[test] fn object_at_on_the_background_or_off_canvas_finds_nothing() { let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); @@ -762,6 +794,28 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() { assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); } +/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is). +#[test] +fn object_at_finds_small_object_in_large_canvas() { + let (w, h) = (4000usize, 3000usize); + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] { + let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh; + let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let truth: Vec = (0..w * h).map(|i| inside(i % w, i / w)).collect(); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32); + let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX); + let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found")); + let v = iou(®, &truth, w, h); + assert!(v > 0.8, "{ow}×{oh}: IoU {v}"); + } + // The flat background around the small object is still nothing. + let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).is_none()); +} + // ---------- Select Subject on the cases reported in #2788 ---------- /// A figure whose upper part is close to the background colour (a cream shirt on a near-white @@ -849,6 +903,50 @@ fn select_subject_leaves_a_background_coloured_gap_open() { assert!(v > 0.9, "IoU {v}"); } +#[test] +fn antialias_blends_edge_pixels_with_their_neighbourhood() { + // A lone selected pixel keeps most of its coverage and its neighbours get half of a ninth; + // neighbours outside the mask count as the pixel itself. + let mut m = vec![0, 0, 0, 0, 255, 0, 0, 0, 0]; + antialias_u8(&mut m, 3, 3); + assert_eq!(m, [14, 14, 14, 14, 142, 14, 14, 14, 14]); +} + +#[test] +fn antialias_skips_only_spans_without_edge_pixels() { + // Every pixel through the per-pixel rule, no span skipped. + let every_pixel = |mask: &mut [u8], w: usize, h: usize| { + let src = mask.to_vec(); + for y in 0..h { + for x in 0..w { + if let Some(v) = edge_value(&src, w, h, x, y) { + mask[y * w + x] = v; + } + } + } + }; + let mut rng = Rng::new(11); + // Widths around the 64-pixel span, single rows and columns, discs, noise and stripes. + for (w, h) in [(1usize, 1usize), (1, 9), (9, 1), (63, 4), (64, 3), (65, 5), (127, 2), (129, 70), (300, 200)] { + let (cx, cy, r) = (w as f32 * 0.6, h as f32 * 0.4, w.min(h) as f32 / 3.0); + let masks: Vec> = vec![ + vec![255; w * h], + vec![0; w * h], + (0..w * h).map(|i| if ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < r { 255 } else { 0 }).collect(), + (0..w * h).map(|_| if rng.f32() < 0.02 { 255 } else { 0 }).collect(), + (0..w * h).map(|_| if rng.f32() < 0.5 { 255 } else { 0 }).collect(), + (0..w * h).map(|i| if (i % w) / 64 % 2 == 0 { 255 } else { 0 }).collect(), + (0..w * h).map(|i| if i % w == w - 1 || i / w == h / 2 { 255 } else { 0 }).collect(), + ]; + for (k, m) in masks.into_iter().enumerate() { + let (mut fast, mut slow) = (m.clone(), m); + antialias_u8(&mut fast, w, h); + every_pixel(&mut slow, w, h); + assert!(fast == slow, "{w}×{h} mask {k}"); + } + } +} + /// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`). #[test] #[ignore = "timing only"] diff --git a/crates/algo/src/segment/workflow_tests.rs b/crates/algo/src/segment/workflow_tests.rs index ca3be5b40f..96c6caeb8f 100644 --- a/crates/algo/src/segment/workflow_tests.rs +++ b/crates/algo/src/segment/workflow_tests.rs @@ -1,4 +1,28 @@ use super::*; + +#[test] +#[ignore = "release Object Finder timing on a 24 MP document"] +#[allow(clippy::assertions_on_constants)] +fn object_finder_large_foreground_release_timing() { + assert!(!cfg!(debug_assertions), "run with --release"); + let (w, h) = (6000, 4000); + let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let sampler = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] { + let start = std::time::Instant::now(); + let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX); + println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0); + if name == "foreground" { + let region = region.unwrap(); + assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9); + assert!(region.at(10, 10) < 0.1); + } else { + assert!(region.is_none()); + } + } +} + #[test] #[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"] #[allow(clippy::assertions_on_constants)] diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 94f744d0be..1e71a502c7 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -761,10 +761,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { } mod distance; +mod lasso; mod marquee; pub use distance::edt; -pub use marquee::{combine_surfaces, ellipse_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}; /// Grows the selection by `r` pixels. pub fn expand(m: &[f32], w: usize, h: usize, r: f32) -> Vec { diff --git a/crates/algo/src/selection/lasso.rs b/crates/algo/src/selection/lasso.rs new file mode 100644 index 0000000000..2471a379b5 --- /dev/null +++ b/crates/algo/src/selection/lasso.rs @@ -0,0 +1,291 @@ +//! Lasso shapes built straight into tiles (#8783): only the tiles an edge crosses are rasterised, +//! and every tile wholly inside the polygon shares one solid tile, so a thin lasso across a huge +//! canvas costs the tiles along its edges, not its bounding box. The bytes are those +//! [`super::polygon`] gives over the canvas. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; + +use crate::photo_util::par_map; + +const TS: usize = TILE_SIZE as usize; + +/// Most partly covered tiles one painted shape (a lasso, a brush stroke) may hold: 2²⁸ pixels' +/// worth, 256 MB as GRAY8. A thin shape touches a whole tile wherever it passes, so this, not +/// its area, bounds what painting it allocates. +pub const MAX_PAINTED_TILES: usize = (1 << 28) / (TS * TS); + +/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or `None` when it misses it. +pub(crate) fn clip_segment(a: (f32, f32), b: (f32, f32), r: Rect) -> Option<((f32, f32), (f32, f32))> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f32, 1.0f32); + for (p, q) in [(-dx, a.0 - r.x0 as f32), (dx, r.x1 as f32 - a.0), (-dy, a.1 - r.y0 as f32), (dy, r.y1 as f32 - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let t = q / p; + if p < 0.0 { + t0 = t0.max(t); + } else { + t1 = t1.min(t); + } + } + } + (t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1))) +} + +/// The pixels the polygon through `points` can touch within `clip` (its bounding box, grown by a +/// pixel, as the Lasso has always rasterised it). +fn polygon_area(points: &[(f32, f32)], clip: Rect) -> Rect { + let (mut x0, mut y0, mut x1, mut y1) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for p in points { + (x0, y0, x1, y1) = (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)); + } + if points.len() < 3 || !(x0 <= x1 && y0 <= y1) { + return Rect::EMPTY; + } + let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32; + Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&clip) +} + +/// The tiles of `clip` the edges of the polygon through `points` cross (with a pixel's margin), +/// in order: the only ones whose pixels can differ. `None` when there are more than `limit`. +pub fn polygon_edge_tiles(points: &[(f32, f32)], clip: Rect, limit: usize) -> Option> { + let area = polygon_area(points, clip); + let mut set = BTreeSet::new(); + if area.is_empty() { + return Some(set); + } + let step = TILE_SIZE as f32 / 4.0; + for (&a, &b) in points.iter().zip(points.iter().cycle().skip(1)) { + let Some((a, b)) = clip_segment(a, b, area.inflate(2)) else { continue }; + let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / step).ceil().clamp(1.0, 1e6) as usize; + let at = |k: usize| { + let t = k as f32 / pieces as f32; + (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }; + for k in 0..pieces { + let (p, q) = (at(k), at(k + 1)); + let lo = |u: f32, v: f32| u.min(v).floor() as i32 - 1; + let hi = |u: f32, v: f32| u.max(v).floor() as i32 + 2; + for tc in Rect::new(lo(p.0, q.0), lo(p.1, q.1), hi(p.0, q.0), hi(p.1, q.1)).intersect(&area).tiles() { + set.insert(tc); + if set.len() > limit { + return None; + } + } + } + } + Some(set) +} + +/// One polygon edge, as [`super::polygon`] keeps it. +struct Edge { + top: f32, + bottom: f32, + a: (f32, f32), + b: (f32, f32), +} + +/// The anti-aliased (even-odd) polygon through `points`, cut to `clip`, as a GRAY8 selection: the +/// bytes of [`super::polygon`] over `clip`, quantised. Edge tiles are rasterised, tiles wholly +/// inside share one solid tile. `None` when the edges cross more than [`MAX_PAINTED_TILES`] tiles. +pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option { + let mut s = Surface::new(PixelFormat::GRAY8); + let area = polygon_area(points, clip); + if area.is_empty() { + return Some(s); + } + let edge_tiles = polygon_edge_tiles(points, clip, MAX_PAINTED_TILES)?; + const SUB: usize = 4; + // Sample rows as `polygon` over `clip` takes them, so the bytes match. + let sample_y = |y: i32, k: usize| clip.y0 as f32 + (y - clip.y0) as f32 + (k as f32 + 0.5) / SUB as f32; + let mut edges: Vec = points + .iter() + .zip(points.iter().cycle().skip(1)) + .filter_map(|(&a, &b)| { + if a.1 < b.1 { + Some(Edge { top: a.1, bottom: b.1, a, b }) + } else if b.1 < a.1 { + Some(Edge { top: b.1, bottom: a.1, a, b }) + } else { + None + } + }) + .collect(); + edges.sort_by(|p, q| p.top.total_cmp(&q.top)); + let crossings = |e: &[&Edge], y: f32, xs: &mut Vec| { + xs.clear(); + xs.extend(e.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0))); + xs.sort_by(f32::total_cmp); + }; + let full = s.solid_tile(&[1.0]); + let blank = s.solid_tile(&[0.0]); + let bands: Vec = Rect::new(area.x0, area.y0, area.x0 + 1, area.y1).tiles().map(|tc| tc.ty).collect(); + let built = par_map(bands.len(), |bi| -> Vec<(TileCoord, Arc)> { + let Some(&ty) = bands.get(bi) else { return Vec::new() }; + let rows = Rect::new(area.x0, ty * TILE_SIZE, area.x1, (ty + 1) * TILE_SIZE).intersect(&area); + // In `x` order (`TileCoord` orders by `tx` first). + let cols: Vec = edge_tiles.iter().filter(|tc| tc.ty == ty).copied().collect(); + let mut out = Vec::new(); + let (first, last) = (sample_y(rows.y0, 0), sample_y(rows.y1 - 1, SUB - 1)); + let reach = edges.partition_point(|e| e.top <= last); + let band_edges = edges.get(..reach).unwrap_or_default(); + let mut xs: Vec = Vec::new(); + // Tiles no edge crosses are wholly in or out: one sample row decides them all. + let y_mid = sample_y(rows.y0 + rows.height() as i32 / 2, 0); + let mid: Vec<&Edge> = band_edges.iter().filter(|e| e.top <= y_mid && e.bottom > y_mid).collect(); + crossings(&mid, y_mid, &mut xs); + for tc in rows.tiles() { + if cols.binary_search(&tc).is_ok() { + continue; + } + let tr = tc.rect().intersect(&rows); + let cx = (tr.x0 + tr.x1) as f32 / 2.0; + if xs.partition_point(|x| *x <= cx) % 2 == 0 { + continue; + } + if tr == tc.rect() { + out.push((tc, Arc::clone(&full))); + } else { + let mut tile = Tile::clone(&blank); + fill(&mut tile, tc, tr, u8::MAX); + out.push((tc, Arc::new(tile))); + } + } + if cols.is_empty() { + return out; + } + // The edge tiles: `polygon`'s scanline, accumulated only over their columns. + let x_of = |tc: &TileCoord| tc.rect().intersect(&rows); + let spans: Vec = cols.iter().map(x_of).collect(); + let mut acc = vec![0.0f32; cols.len() * TS]; + let mut tiles: Vec = cols.iter().map(|_| Tile::clone(&blank)).collect(); + let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable(); + let mut active: Vec<&Edge> = Vec::new(); + let origin = clip.x0 as f32; + for y in rows.y0..rows.y1 { + acc.fill(0.0); + for k in 0..SUB { + let sy = sample_y(y, k); + while let Some(e) = pending.next_if(|e| e.top <= sy) { + active.push(e); + } + active.retain(|e| e.bottom > sy); + if active.is_empty() { + continue; + } + crossings(&active, sy, &mut xs); + for pair in xs.as_chunks::<2>().0 { + let (x0, x1) = ((pair[0] - origin).max(0.0), (pair[1] - origin).min(clip.width() as f32)); + if x1 <= x0 { + continue; + } + let (c0, c1) = (clip.x0 + x0.floor() as i32, clip.x0 + x1.ceil() as i32); + let start = spans.partition_point(|r| r.x1 <= c0); + for (ci, r) in spans.iter().enumerate().skip(start).take_while(|(_, r)| r.x0 < c1) { + for x in c0.max(r.x0)..c1.min(r.x1) { + let cx = (x - clip.x0) as f32; + let cov = (cx + 1.0).min(x1) - cx.max(x0); + if let Some(v) = acc.get_mut(ci * TS + (x - r.x0) as usize) { + *v += cov.max(0.0) / SUB as f32; + } + } + } + } + } + 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 }; + for (d, v) in dst.iter_mut().zip(row) { + *d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + } + for (tc, tile) in cols.into_iter().zip(tiles) { + let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + if hi > 0 { + out.push((tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) })); + } + } + out + }); + s.put_tiles(built.into_iter().flatten()); + Some(s) +} + +/// Sets `tr` (within tile `tc`) to `v`. +fn fill(tile: &mut Tile, tc: TileCoord, tr: Rect, v: u8) { + let o = tc.rect(); + for y in tr.y0..tr.y1 { + let base = (y - o.y0) as usize * TS; + if let Some(row) = tile.bytes_mut().get_mut(base + (tr.x0 - o.x0) as usize..base + (tr.x1 - o.x0) as usize) { + row.fill(v); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The dense rasteriser over `clip`, quantised as the Lasso stores it. + fn dense(points: &[(f32, f32)], clip: Rect) -> Surface { + let m: Vec = super::super::polygon(points, clip, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); + let mut s = Surface::from_interleaved(PixelFormat::GRAY8, clip, &m); + s.prune(); + s + } + + #[test] + fn matches_the_dense_polygon() { + let clip = Rect::new(-40, 10, 1300, 900); + let shapes: [&[(f32, f32)]; 6] = [ + &[(10.5, 20.25), (1200.0, 40.0), (600.0, 880.7)], + // Whole tiles inside, edges exactly on tile boundaries. + &[(0.0, 256.0), (768.0, 256.0), (768.0, 768.0), (0.0, 768.0)], + // Self-intersecting (even-odd), past the clip. + &[(-300.0, -50.0), (1500.0, 950.0), (1500.0, -50.0), (-300.0, 950.0)], + &[(100.0, 100.0), (101.0, 100.0), (100.5, 700.0)], + &[(5.0, 15.0), (5.0, 15.0), (5.0, 15.0)], + &[(2000.0, 2000.0), (2100.0, 2000.0), (2000.0, 2100.0)], + ]; + for pts in shapes { + let s = polygon_surface(pts, clip).expect("within the tile limit"); + let d = dense(pts, clip); + let area = clip.union(&s.tile_bounds()); + assert!(s.read_region(area) == d.read_region(area), "{pts:?}"); + } + } + + /// A thin sliver across a 100,000 px canvas: only the tiles along it are built (the dense + /// rasteriser allocated its 10-billion-pixel bounding box; the #8783 review). + #[test] + fn a_thin_sliver_across_a_huge_canvas_costs_its_edge_tiles() { + let clip = Rect::new(0, 0, 100_000, 100_000); + let sliver = [(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)]; + let tiles = polygon_edge_tiles(&sliver, clip, MAX_PAINTED_TILES).expect("within the limit"); + assert!(tiles.len() < 2000, "{}", tiles.len()); + let s = polygon_surface(&sliver, clip).expect("within the limit"); + assert!(s.tile_count() <= tiles.len()); + assert!(s.pixel(50_000, 50_000)[0] > 0.0 && s.pixel(50_000, 40_000)[0] == 0.0); + // A zigzag that crosses more tiles than the limit is refused. + let zigzag: Vec<(f32, f32)> = (0..200).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 500.0)).collect(); + assert!(polygon_edge_tiles(&zigzag, clip, MAX_PAINTED_TILES).is_none()); + assert!(polygon_surface(&zigzag, clip).is_none()); + } + + #[test] + fn a_huge_polygon_shares_one_tile_inside() { + let clip = Rect::new(0, 0, 51_200, 51_200); + let s = polygon_surface(&[(-10.0, -10.0), (60_000.0, -10.0), (60_000.0, 60_000.0), (-10.0, 60_000.0)], clip).expect("no edge tiles"); + let shared = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect::>(); + assert!(s.tile_count() == 200 * 200 && shared.len() == 1, "{} tiles, {} distinct", s.tile_count(), shared.len()); + } +} diff --git a/crates/algo/src/selection/marquee.rs b/crates/algo/src/selection/marquee.rs index 7dd861164a..c4830ae134 100644 --- a/crates/algo/src/selection/marquee.rs +++ b/crates/algo/src/selection/marquee.rs @@ -236,6 +236,60 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3 out } +/// [`combine_surfaces`] for an `f` that keeps `a` wherever `b` reads 0 (Add, Subtract), visiting +/// only `b`'s tiles within `area`: the rest of `a` (its other tiles and its untouched value) is +/// kept as it is, so painting a thin stroke across a huge canvas costs the tiles it paints, not +/// the canvas (#8783). Falls back to [`combine_surfaces`] over `area` for other inputs. +pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface { + let gray = PixelFormat::GRAY8; + let lut: Vec = (0..=u16::MAX).map(|i| encode8(f(decode8((i >> 8) as u8), decode8(i as u8)))).collect(); + let pair = |x: u8, y: u8| lut.get(usize::from(x) << 8 | usize::from(y)).copied().unwrap_or(0); + if a.format() != gray || b.format() != gray || b.default_bytes() != [0] || !(0..=255u8).all(|v| pair(v, 0) == v) { + return combine_surfaces(a, b, area, f); + } + let da = a.default_bytes().first().copied().unwrap_or(0); + let mut out = a.clone(); + let (full, untouched) = (out.solid_tile(&[1.0]), out.solid_tile(&[decode8(da)])); + let coords: Vec = b.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&area).is_empty()).collect(); + // Each tile: `Some(tile)` to store, `None` when it reads as `a`'s untouched value. + let built = par_map(coords.len(), |k| { + let tc = *coords.get(k)?; + let rect = tc.rect(); + 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 (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 }; + for (d, y) in dst.iter_mut().zip(src) { + *d = pair(*d, *y); + } + } + let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + Some(( + tc, + if lo == da && hi == da { + None + } else if lo == u8::MAX { + Some(Arc::clone(&full)) + } else { + Some(Arc::new(tile)) + }, + )) + }); + for (tc, tile) in built.into_iter().flatten() { + match tile { + Some(t) => out.put_tiles([(tc, t)]), + // `take_tiles` only allocates for a tile that is missing; this one is there. + None if out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())), + None => {} + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -311,4 +365,30 @@ mod tests { } } } + + /// Add and Subtract through only the new shape's tiles give the bytes of the whole-area + /// combination, keep the old selection's other tiles shared, and keep an untouched value + /// (a layer mask's reveal-all) outside them. + #[test] + fn sparse_combination_matches_the_whole_area_one() { + let area = Rect::new(0, 0, 1100, 700); + let mut rect = Surface::new(PixelFormat::GRAY8); + rect.fill_rect(Rect::from_xywh(200, 100, 600, 520), &[1.0]); + let ell = old_ellipse(Rect::from_xywh(-60, 250, 700, 500), area, true); + let far = ellipse_surface(Rect::from_xywh(900, 20, 150, 150), area, true); + let ops: [fn(f32, f32) -> f32; 2] = [|a, b| a.max(b), |a, b| (a - b).max(0.0)]; + for (a, b) in [(&rect, &ell), (&ell, &rect), (&ell, &ell), (&rect, &far)] { + for f in ops { + let sparse = combine_sparse(a, b, area, f); + assert!(sparse.read_region(area) == old_combine(a, b, area, f).read_region(area)); + assert!(sparse.tiles().all(|(_, t)| t.bytes().iter().any(|v| *v != 0)), "no empty tiles kept"); + } + } + let kept = combine_sparse(&rect, &far, area, |a, b| a.max(b)); + let tc = TileCoord::new(1, 1); + assert!(Arc::ptr_eq(kept.tile(tc).expect("kept"), rect.tile(tc).expect("there")), "untouched tiles stay shared"); + let reveal = Surface::with_default(PixelFormat::GRAY8, &[1.0]); + let cut = combine_sparse(&reveal, &far, area, |a, b| (a - b).max(0.0)); + assert!(cut.pixel(5000, 5000)[0] == 1.0 && cut.pixel(975, 95)[0] == 0.0); + } } diff --git a/crates/algo/src/smart_blur_tests.rs b/crates/algo/src/smart_blur_tests.rs index 47290e2425..505764e42e 100644 --- a/crates/algo/src/smart_blur_tests.rs +++ b/crates/algo/src/smart_blur_tests.rs @@ -1,6 +1,6 @@ //! The pre-#7921 Smart Blur (every neighbour read per channel through `Image::get`) stays as an //! exact oracle: the offset fast path must give the same bits for every mode, depth and layout. -use super::*; +use super::{BlurQuality, Ctx, Image, MAXC, Rect, SmartBlurMode, native, ncol, smart_blur, unpremul_px, xy}; use photocraft_color::ColorMode; #[allow(clippy::too_many_arguments)] diff --git a/crates/algo/src/stylize2/tests.rs b/crates/algo/src/stylize2/tests.rs index 3d7f6b5235..c7139153af 100644 --- a/crates/algo/src/stylize2/tests.rs +++ b/crates/algo/src/stylize2/tests.rs @@ -29,14 +29,25 @@ const MODELS: [ColorMode; 8] = #[test] fn swept_blocks_match_the_pixel_search() { 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. + // A run takes a quarter of the rest, a different quarter each day. + let mut seen = std::collections::HashSet::new(); + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; for mode in MODELS { for alpha in [false, true] { let n = PixelFormat::new(mode, SampleType::F32, alpha).channels(); let ctx = Ctx { bounds, mode, alpha }; + let repeat = !seen.insert((n, alpha, mode == ColorMode::Cmyk)); for kind in 0..5 { let src = image(bounds.inflate(20), n, kind); for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] { - for flags in 0..8 { + for flags in (0..8).filter(|f| !repeat || f & 1 != 0) { + case += 1; + if !sweep.includes(case) { + continue; + } let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index b7fc92113c..6d7e879346 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -640,3 +640,12 @@ fn mix_selection_reads_the_selection_once_per_tile() { assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ"); } } + +#[test] +fn blur_halos_follow_the_clamped_radius_and_never_overflow() { + let smart = |radius| FilterParams::SmartBlur { radius, threshold: 25.0, quality: BlurQuality::default(), mode: SmartBlurMode::default() }; + assert_eq!(smart(1e9).halo(), Halo::Radius(101)); + assert_eq!(smart(f32::INFINITY).halo(), Halo::Radius(101)); + let gaussian = FilterParams::GaussianBlur { radius: f32::INFINITY }; + assert_eq!(gaussian.halo(), Halo::Radius(i32::MAX)); +} diff --git a/crates/algo/src/tests_tilt_shift.rs b/crates/algo/src/tests_tilt_shift.rs index 88066ca9d6..b82dd1c2c4 100644 --- a/crates/algo/src/tests_tilt_shift.rs +++ b/crates/algo/src/tests_tilt_shift.rs @@ -52,11 +52,19 @@ fn tilt_shift_matches_frozen_kernel_at_band_boundaries_and_edges() { } cases.push(params(80.0, (0.5, 0.5), 0.0, 5.0, 0.1)); cases.push(params(80.0, (-5.0, -5.0), 27.0, 0.1, 0.15)); + // The channel count and alpha are independent of the band geometry: a run takes a quarter of + // their combinations, a different quarter each day. + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; for ch in 1..=6 { let src = image(bounds.inflate(12), ch); for alpha in [false, true] { let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha }; for p in &cases { + case += 1; + if !sweep.includes(case) { + continue; + } check(&src, bounds, &ctx, p); check(&src, Rect::new(-7, 17, 15, 39), &ctx, p); } @@ -146,6 +154,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() { selection.fill_rect(bounds, &[0.4]); selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]); selection.fill_rect(Rect::new(10, 20, 20, 30), &[0.0]); + // Pixel formats and tile layouts are independent: a run takes a quarter of their combinations, + // a different quarter each day. + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; for mode in [ ColorMode::Rgb, ColorMode::Grayscale, @@ -167,6 +179,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() { assert_eq!(p.halo_for(bounds), crate::Halo::Radius(radius)); for extent in [None, Some(bounds)] { for tile in [16, 47, 128] { + case += 1; + if !sweep.includes(case) { + continue; + } let got = crate::apply_tiled(&surface, &p, area, bounds, Some(&selection), tile, extent); let mut want = surface.clone(); for y in (area.y0..area.y1).step_by(tile as usize) { diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 428ae3cd09..3edd0a1879 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -512,8 +512,11 @@ fn warp( any = true; let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n; for c in 0..a { - // Integer storage clips on write; float colours retain their signed/HDR range. - outp[o + c] = (acc[c] / al) as f32; + // Un-premultiply by the unclamped alpha: bicubic overshoots it past 1 next to + // partly transparent pixels, and dividing by the clamped 1 would lighten the + // colour by the overshoot (#6203). Integer storage clips on write; float + // colours retain their signed/HDR range. + outp[o + c] = (acc[c] / acc[a]) as f32; } outp[o + a] = al as f32; } @@ -837,6 +840,32 @@ mod tests { assert!((p[0] - 0.2).abs() < 0.01 && p[3] > 0.99, "{p:?}"); } + /// #6203: a bicubic rotation of a sprite whose edge pixels are partly transparent overshoots + /// its alpha past 1. The colour must stay the sprite's, not grow with the overshoot into a + /// light border. + #[test] + fn rotation_keeps_the_colour_of_a_soft_edged_sprite() { + let mut s = rgba(); + s.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]); + s.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]); + let (c, k) = (20.0, std::f64::consts::FRAC_1_SQRT_2 * 10.0); + let q = [[c, c - 2.0 * k], [c + 2.0 * k, c], [c, c + 2.0 * k], [c - 2.0 * k, c]]; + let h = Homography::rect_to_quad([10.0, 10.0, 30.0, 30.0], q).unwrap(); + for interp in [Interp::Bicubic, Interp::Bilinear] { + let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap(); + let b = out.content_bounds(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let p = out.pixel(x, y); + if p[3] > 0.0 { + let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max); + assert!(off <= 1.0 / 255.0, "{interp:?} ({x}, {y}): {p:?}"); + } + } + } + } + } + #[test] fn large_downscale_is_prefiltered() { // A 1px checkerboard shrunk 8× must come out ~50% grey, not aliased black/white. diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 1cf39b23ec..25ae5609ef 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -324,33 +324,114 @@ pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2 Ok(Some(out)) } +/// The fixed frame of one clone stroke: the source and destination planes, the source offset in +/// plane-metric units and the brush radius, all set at the stroke's anchor (its first dab). +struct CloneFrame { + hs: Homography, + ls: (f64, f64), + hd: Homography, + hd_inv: Homography, + ld: (f64, f64), + offset: [f64; 2], + r_m: f64, +} + +impl CloneFrame { + fn new(scene: &Scene, source: [f64; 2], anchor: [f64; 2], size: f64) -> Option { + let (ps, pd) = (scene.plane_at(source)?, scene.plane_at(anchor)?); + let ((hs, lus, lvs), (hd, lud, lvd)) = (scene.frame(ps)?, scene.frame(pd)?); + let (hs_inv, hd_inv) = (hs.inverse()?, hd.inverse()?); + let ms = metric(&hs_inv, (lus, lvs), source); + let md0 = metric(&hd_inv, (lud, lvd), anchor); + // Brush radius in metric units (from the image size at the anchor). + let probe = metric(&hd_inv, (lud, lvd), [anchor[0] + size / 2.0, anchor[1]]); + let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9); + let offset = [ms[0] - md0[0], ms[1] - md0[1]]; + (offset.iter().all(|v| v.is_finite()) && r_m.is_finite()).then_some(CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m }) + } + + /// The source point (image px) that destination `p` copies from. + fn source_of(&self, p: [f64; 2]) -> [f64; 2] { + let m = metric(&self.hd_inv, self.ld, p); + let (x, y) = self.hs.apply((m[0] + self.offset[0]) / self.ls.0, (m[1] + self.offset[1]) / self.ls.1); + [x, y] + } +} + +/// Image point → plane-metric coordinates through the plane's inverse homography. +fn metric(inv: &Homography, l: (f64, f64), p: [f64; 2]) -> [f64; 2] { + let (u, v) = inv.apply(p[0], p[1]); + [u * l.0, v * l.1] +} + /// Perspective clone stamp: one stroke of dabs at image points `points` on the destination /// plane, copying from `source` (image px) with the offset fixed in plane-metric units at the /// first dab. `size` is the brush diameter in px at the first dab; `hardness` 0..=1. pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: &[[f64; 2]], size: f64, hardness: f64, opacity: f32) -> usize { let Some(first) = points.first() else { return 0 }; - let (Some(ps), Some(pd)) = (scene.plane_at(source), scene.plane_at(*first)) else { return 0 }; - let (Some((hs, lus, lvs)), Some((hd, lud, lvd))) = (scene.frame(ps), scene.frame(pd)) else { return 0 }; - let (Some(hs_inv), Some(hd_inv)) = (hs.inverse(), hd.inverse()) else { return 0 }; - let metric = |inv: &Homography, lu: f64, lv: f64, p: [f64; 2]| { - let (u, v) = inv.apply(p[0], p[1]); - [u * lu, v * lv] - }; - let ms = metric(&hs_inv, lus, lvs, source); - let md0 = metric(&hd_inv, lud, lvd, *first); - let offset = [ms[0] - md0[0], ms[1] - md0[1]]; - // Brush radius in metric units (from the image size at the first dab). - let probe = metric(&hd_inv, lud, lvd, [first[0] + size / 2.0, first[1]]); - let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9); + clone_dabs(scene, surf, source, *first, points, size, hardness, opacity, None).0 +} + +/// Where a clone stroke that starts at `anchor` copying from `source` samples for destination +/// `p`: the same plane-metric offset. A live preview paints a stroke in pieces with it, and +/// Aligned strokes keep the first stroke's offset. `None` when `source` or `anchor` is off the +/// planes. +pub fn clone_source(scene: &Scene, source: [f64; 2], anchor: [f64; 2], p: [f64; 2]) -> Option<[f64; 2]> { + let s = CloneFrame::new(scene, source, anchor, 1.0)?.source_of(p); + s.iter().all(|v| v.is_finite()).then_some(s) +} + +/// [`clone_stroke`]'s dabs at `points` for a stroke anchored at `anchor` (its first dab, which +/// fixes the offset and the brush's metric size). Samples a snapshot taken at this call. +/// For incremental painting use [`clone_dabs_from`] with one snapshot per stroke. +/// With `clip`, every dab is cut to it before any read or write (a document passes its canvas, +/// so far off-canvas points allocate no tiles). Returns the dabs painted and the area they changed. +#[allow(clippy::too_many_arguments)] +pub fn clone_dabs( + scene: &Scene, + surf: &mut Surface, + source: [f64; 2], + anchor: [f64; 2], + points: &[[f64; 2]], + size: f64, + hardness: f64, + opacity: f32, + clip: Option, +) -> (usize, Rect) { let src_copy = surf.clone(); + clone_dabs_from(scene, surf, &src_copy, source, anchor, points, size, hardness, opacity, clip) +} + +/// Paint a batch of one stroke using the same immutable source snapshot for every batch. +/// Source and destination must have the same pixel format; otherwise no dabs are painted. +/// `clip` as in [`clone_dabs`]. +#[allow(clippy::too_many_arguments)] +pub fn clone_dabs_from( + scene: &Scene, + surf: &mut Surface, + src_copy: &Surface, + source: [f64; 2], + anchor: [f64; 2], + points: &[[f64; 2]], + size: f64, + hardness: f64, + opacity: f32, + clip: Option, +) -> (usize, Rect) { + if surf.format() != src_copy.format() { + return (0, Rect::EMPTY); + } + let Some(f) = CloneFrame::new(scene, source, anchor, size) else { return (0, Rect::EMPTY) }; + let CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m } = f; let fmt = surf.format(); let n = fmt.channels(); // Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result. let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]); let mut dabs = 0; + let mut changed_area = Rect::EMPTY; for p in points { - let mc = metric(&hd_inv, lud, lvd, *p); + let mc = metric(&hd_inv, (lud, lvd), *p); // Image-space footprint: map a metric circle's bounding points. let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| { let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; @@ -359,12 +440,19 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv); bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)]; } + if bb.iter().any(|v| !v.is_finite()) { + return Rect::EMPTY; + } Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1)) }; let area = footprint(&hd, lud, lvd, mc); if area.is_empty() || area.width() > 4096 || area.height() > 4096 { continue; } + let area = clip.map_or(area, |c| area.intersect(&c)); + if area.is_empty() { + continue; + } // The source footprint, read once; `sample` reads any pixel outside it from the surface. // Skipped when it dwarfs the destination (cloning from near to far in strong perspective): // decoding it would cost more than the few per-pixel reads it saves. @@ -385,9 +473,9 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: let mut changed = false; for (i, px) in data.chunks_exact_mut(n).enumerate() { let (x, y) = (area.x0 as f64 + (i % w) as f64 + 0.5, area.y0 as f64 + (i / w) as f64 + 0.5); - let m = metric(&hd_inv, lud, lvd, [x, y]); + let m = metric(&hd_inv, (lud, lvd), [x, y]); let d = (m[0] - mc[0]).hypot(m[1] - mc[1]) / r_m; - if d >= 1.0 { + if d >= 1.0 || d.is_nan() { continue; } let hard = hardness.clamp(0.0, 0.99); @@ -396,7 +484,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: // Source pixel at the same metric offset. let sm = [m[0] + offset[0], m[1] + offset[1]]; let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs); - sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s); + sample(src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s); for c in 0..n { px[c] += (s[c] - px[c]) * k; } @@ -405,9 +493,10 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: if changed { surf.write_region(area, &data); dabs += 1; + changed_area = if changed_area.is_empty() { area } else { changed_area.union(&area) }; } } - dabs + (dabs, changed_area) } /// Pixels of `area` read from the source surface (`read_region` layout). @@ -421,6 +510,10 @@ struct Window { /// read from `s`. fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec, out: &mut [f32]) { let n = out.len(); + if !x.is_finite() || !y.is_finite() { + out.fill(0.0); + return; + } let (x0, y0) = (x.floor() as i32, y.floor() as i32); let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32); let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1)); @@ -537,6 +630,42 @@ mod tests { let n = clone_stroke(&sc, &mut s, [sx, sy], &[[dx, dy]], 10.0, 0.8, 1.0); assert_eq!(n, 1); assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32)); + // The stroke's anchor copies from the source itself. + let back = clone_source(&sc, [sx, sy], [dx, dy], [dx, dy]).unwrap(); + assert!((back[0] - sx).abs() < 1e-6 && (back[1] - sy).abs() < 1e-6, "{back:?}"); + } + #[test] + fn incremental_clone_uses_one_source_snapshot_per_stroke() { + let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0); + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut original = Surface::new(fmt); + for x in 0..100 { + original.fill_rect(Rect::new(x, 0, x + 1, 100), &[x as f32 / 100.0, (x % 7) as f32 / 6.0, 0.3, 1.0]); + } + let points = [[40.0, 50.0], [44.0, 50.0], [48.0, 50.0], [52.0, 50.0]]; + let mut whole = original.clone(); + assert_eq!(clone_stroke(&sc, &mut whole, [36.0, 50.0], &points, 12.0, 0.8, 1.0), 4); + let mut pieces = original.clone(); + for part in points.chunks(2) { + clone_dabs_from(&sc, &mut pieces, &original, [36.0, 50.0], points[0], part, 12.0, 0.8, 1.0, None); + } + assert_eq!(whole.read_region(Rect::new(0, 0, 100, 100)), pieces.read_region(Rect::new(0, 0, 100, 100))); + } + } + + /// A clip cuts every dab before it reads or writes: dabs far outside it allocate no tiles. + #[test] + fn clipped_clone_allocates_no_tiles_outside_the_clip() { + let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0); + let clip = Rect::new(0, 0, 100, 100); + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(clip, &[0.5, 0.5, 0.5, 1.0]); + let tiles = s.tiles().count(); + let points = [[50.0, 50.0], [-40.0, 50.0], [5000.0, 5000.0], [-90_000.0, 70_000.0]]; + let (dabs, area) = clone_dabs(&sc, &mut s, [40.0, 50.0], points[0], &points, 20.0, 0.8, 1.0, Some(clip)); + assert_eq!(dabs, 1); + assert!(clip.contains_rect(&area), "{area:?}"); + assert_eq!(s.tiles().count(), tiles); } #[test] diff --git a/crates/algo/src/video.rs b/crates/algo/src/video.rs index bbd093b0a8..512768fb7a 100644 --- a/crates/algo/src/video.rs +++ b/crates/algo/src/video.rs @@ -88,3 +88,162 @@ pub(crate) fn ntsc(src: &Image, out: Rect, ctx: &Ctx) -> Vec { } res } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FilterParams, apply}; + use photocraft_color::{PixelFormat, SampleType}; + use photocraft_raster::Surface; + + const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + + fn tolerance(depth: SampleType) -> f32 { + match depth { + SampleType::U8 => 1.0 / 255.0, + SampleType::U16 => 1.0 / 65535.0, + SampleType::F32 => 1e-6, + } + } + + fn rows(depth: SampleType, bounds: Rect, pixels: &[[f32; 4]]) -> Surface { + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + for (row, px) in pixels.iter().enumerate() { + let y = bounds.y0 + row as i32; + src.fill_rect(Rect::new(bounds.x0, y, bounds.x1, y + 1), px); + } + src + } + + #[test] + fn deinterlace_all_field_and_interpolation_choices_use_bounds_relative_rows() { + // Odd document origin: parity is relative to the bounds, not absolute y. + let b = Rect::new(-2, 11, 0, 16); + let pixels = [0.0, 0.2, 0.6, 0.8, 1.0].map(|v| [v, 1.0 - v, v / 2.0, 1.0]); + for depth in DEPTHS { + let src = rows(depth, b, &pixels); + let before = src.read_region(b); + for (eliminate_even, interpolate, neighbours) in [ + (false, false, [(1, 1), (1, 1), (1, 1), (3, 3), (3, 3)]), + (false, true, [(1, 1), (1, 1), (1, 3), (3, 3), (3, 3)]), + (true, false, [(0, 0), (0, 0), (2, 2), (2, 2), (4, 4)]), + (true, true, [(0, 0), (0, 2), (2, 2), (2, 4), (4, 4)]), + ] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None); + for (row, (a, c)) in neighbours.into_iter().enumerate() { + for x in b.x0..b.x1 { + let (pa, pc) = (src.pixel(x, b.y0 + a), src.pixel(x, b.y0 + c)); + let actual = out.pixel(x, b.y0 + row as i32); + for channel in 0..4 { + let expected = (pa[channel] + pc[channel]) / 2.0; + assert!( + (actual[channel] - expected).abs() <= tolerance(depth), + "{depth:?}, even={eliminate_even}, interp={interpolate}, row={row}: {actual:?}" + ); + } + } + } + } + assert_eq!(src.read_region(b), before); + } + } + + #[test] + fn deinterlace_interpolation_weights_colour_by_alpha() { + let b = Rect::new(0, 0, 1, 3); + for depth in DEPTHS { + for [a, c] in [[0.25, 0.75], [1.0, 0.0], [0.0, 0.0]] { + let src = rows(depth, b, &[[1.0, 0.0, 0.0, a], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, c]]); + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even: true, interpolate: true }, b, b, None); + let (a, c) = (src.pixel(0, 0)[3], src.pixel(0, 2)[3]); + let total = a + c; + let expected = if total > 0.0 { [a / total, 0.0, c / total, total / 2.0] } else { [0.0; 4] }; + let actual = out.pixel(0, 1); + for (v, want) in actual.iter().zip(expected) { + assert!((v - want).abs() <= tolerance(depth), "{depth:?}: {actual:?} != {expected:?}"); + } + assert_eq!(out.pixel(0, 0), src.pixel(0, 0)); + assert_eq!(out.pixel(0, 2), src.pixel(0, 2)); + } + } + } + + #[test] + fn deinterlace_one_and_two_row_images_without_alpha_handle_missing_neighbours() { + for depth in DEPTHS { + for height in [1, 2] { + let b = Rect::new(0, -3, 1, -3 + height); + let mut src = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + src.fill_rect(b, &[0.2]); + if height == 2 { + src.fill_rect(Rect::new(0, -2, 1, -1), &[0.8]); + } + for eliminate_even in [false, true] { + for interpolate in [false, true] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None); + let kept_y = if height == 2 && !eliminate_even { -2 } else { -3 }; + for y in b.y0..b.y1 { + assert_eq!(out.pixel(0, y), src.pixel(0, kept_y), "{depth:?}, height={height}, even={eliminate_even}, interp={interpolate}"); + } + } + } + } + } + } + + #[test] + fn deinterlace_leaves_pixels_outside_reference_bounds_unchanged() { + let area = Rect::new(0, 0, 1, 5); + let b = Rect::new(0, 1, 1, 4); + let src = rows(SampleType::F32, area, &[[0.1; 4], [0.2; 4], [0.4; 4], [0.8; 4], [1.0; 4]]); + for eliminate_even in [false, true] { + for interpolate in [false, true] { + let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, area, b, None); + assert_eq!(out.pixel(0, 0), src.pixel(0, 0)); + assert_eq!(out.pixel(0, 4), src.pixel(0, 4)); + } + } + } + + #[test] + fn ntsc_safe_colours_and_alpha_are_bit_exact_at_all_depths() { + let b = Rect::new(0, 0, 1, 5); + for depth in DEPTHS { + let src = rows(depth, b, &[[0.0, 0.0, 0.0, 0.0], [0.5, 0.5, 0.5, 0.25], [1.0, 1.0, 1.0, 0.75], [0.2, 0.3, 0.4, 1.0], [1.0, 0.0, 1.0, 1.0]]); + let out = apply(&src, &FilterParams::NtscColors, b, b, None); + assert_eq!(out.read_region(b), src.read_region(b), "{depth:?}"); + } + } + + fn luma_chroma(p: &[f32]) -> (f32, f32) { + let y = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; + let i = 0.595716 * p[0] - 0.274453 * p[1] - 0.321263 * p[2]; + let q = 0.211456 * p[0] - 0.522591 * p[1] + 0.311135 * p[2]; + (y, i.hypot(q)) + } + + #[test] + fn ntsc_saturated_colours_keep_luma_alpha_and_meet_composite_limits() { + // Independent signal checks: -20..120 IRE relative to the 7.5..100 IRE luma range. + let (lo, hi) = ((-20.0 - 7.5) / 92.5, (120.0 - 7.5) / 92.5); + let colours = [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.25], [1.0, 1.0, 0.0, 0.5], [0.0, 1.0, 1.0, 0.75]]; + let b = Rect::new(0, 0, 1, colours.len() as i32); + for depth in DEPTHS { + let src = rows(depth, b, &colours); + let out = apply(&src, &FilterParams::NtscColors, b, b, None); + // The rounded YIQ inverse contributes < 0.0003; integer writes add one sample step. + let eps = 0.0003 + 2.0 * tolerance(depth); + for y in b.y0..b.y1 { + let (before, after) = (src.pixel(0, y), out.pixel(0, y)); + let (y0, c0) = luma_chroma(&before); + let (y1, c1) = luma_chroma(&after); + assert!(y0 - c0 < lo || y0 + c0 > hi, "fixture must require correction"); + assert!(c1 < c0, "chroma not reduced: {depth:?} {after:?}"); + assert!((y1 - y0).abs() <= eps, "luma changed: {depth:?} {before:?} -> {after:?}"); + assert!(y1 - c1 >= lo - eps && y1 + c1 <= hi + eps, "signal out of gamut: {depth:?} {after:?}"); + assert!(after[..3].iter().all(|v| v.is_finite() && *v >= 0.0 && *v <= 1.0)); + assert_eq!(after[3], before[3], "alpha changed: {depth:?}"); + } + } + } +} diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 23c5d29237..87c6bde2c3 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -164,6 +164,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 /// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips /// the remaining tiles; the caller discards the result. +/// Destination tiles one triangle render may keep bookkeeping for (a 1M-tile render is 69 Gpx). +const MAX_DST_TILES: u64 = 1 << 20; +/// Source pixels one destination tile may read densely. +const MAX_FOOTPRINT_PIXELS: u64 = 1 << 25; + fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface { let mut fmt = src.format(); let converted; @@ -190,6 +195,12 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris y1 = y1.max(v.0[1]); } let dst = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1); + // Bookkeeping is kept per destination tile, so a mesh flung across the whole coordinate range + // would ask for millions of entries before drawing anything: draw nothing instead. + let ts_px = photocraft_geom::TILE_SIZE as u64; + if u64::from(dst.width()).div_ceil(ts_px).saturating_add(1).saturating_mul(u64::from(dst.height()).div_ceil(ts_px).saturating_add(1)) > MAX_DST_TILES { + return out; + } // Triangles: (vertex indices), binned per destination tile. let tiles: Vec = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect(); let mut bins: Vec> = vec![Vec::new(); tiles.len()]; @@ -304,7 +315,11 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris return None; } let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&src_rect); - if foot.is_empty() || !src.has_tiles_in(foot) { + // Sparse components far apart in the source can land in one tile: the dense window + // between them is not read (the tile stays empty). The pixel-area cap is checked first: + // `has_tiles_in` walks the source tile grid, so an oversized footprint must return + // without paying for that scan. + if foot.is_empty() || foot.size().area() > MAX_FOOTPRINT_PIXELS || !src.has_tiles_in(foot) { return None; } let mut px = src.read_region(foot); @@ -431,7 +446,8 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris any = true; let b = o * n; for c in 0..a { - outp[b + c] = (acc[c] / al).clamp(0.0, 1.0) as f32; + // The unclamped alpha, so a bicubic overshoot past 1 doesn't lighten the colour (#6203). + outp[b + c] = (acc[c] / acc[a]).clamp(0.0, 1.0) as f32; } outp[b + a] = al as f32; } @@ -509,6 +525,18 @@ mod tests { surf } + #[test] + fn a_mesh_flung_across_the_coordinate_range_gets_no_per_tile_bookkeeping() { + // About 8000 × 8000 destination tiles from three vertices. + let src = sample(SampleType::U8); + let far = 1_000_000.0; + let verts = [([-far, -far], [0.0, 0.0]), ([far, -far], [40.0, 0.0]), ([0.0, far], [0.0, 24.0])]; + let t = std::time::Instant::now(); + let out = warp_triangles(&src, Rect::new(0, 0, 40, 24), &verts, &[[0, 1, 2]], Interp::Nearest); + assert!(out.content_bounds().is_empty()); + assert!(t.elapsed().as_secs() < 5); + } + #[test] fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() { // #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in @@ -588,6 +616,28 @@ mod tests { } } + #[test] + fn a_rotated_soft_edged_sprite_keeps_its_colour() { + // #6203, as in Free Transform: the bicubic alpha overshoots past 1 next to the half + // transparent edge, and must not lighten the colour. + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + src.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]); + src.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]); + let k = std::f64::consts::FRAC_1_SQRT_2; + let rotate = |x: f64, y: f64| (20.0 + (x - 20.0) * k - (y - 20.0) * k, 20.0 + (x - 20.0) * k + (y - 20.0) * k); + let out = warp_mesh_surface(&src, src.content_bounds(), &rotate, Interp::Bicubic); + let b = out.content_bounds(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let p = out.pixel(x, y); + if p[3] > 0.0 { + let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max); + assert!(off <= 1.0 / 255.0, "({x}, {y}): {p:?}"); + } + } + } + } + #[test] fn translation_and_known_arc_points() { let src = sample(SampleType::U8); diff --git a/crates/algo/tests/it/liquify_freeze_formats.rs b/crates/algo/tests/it/liquify_freeze_formats.rs new file mode 100644 index 0000000000..e5def7106f --- /dev/null +++ b/crates/algo/tests/it/liquify_freeze_formats.rs @@ -0,0 +1,107 @@ +//! Final-render coverage for #7989, extending the boundary fix in #8012. +use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn bounds() -> Rect { + // Cross the tile boundary at x=256, with a nonzero origin on both axes. + Rect::new(192, -32, 320, 96) +} + +fn source(mode: ColorMode, sample: SampleType) -> Surface { + let format = PixelFormat::new(mode, sample, true); + let mut pixels = Vec::with_capacity(128 * 128 * format.channels()); + for y in 0..128 { + for x in 0..128 { + let alpha = if (48..52).contains(&x) { 0.0 } else { ((x + 3 * y) % 5 + 1) as f32 / 5.0 }; + for channel in 0..mode.color_channels() { + pixels.push(if alpha == 0.0 { 0.0 } else { ((x * 19 + y * 7 + channel * 43) % 251 + 2) as f32 / 255.0 }); + } + pixels.push(alpha); + } + } + let mut source = Surface::new(format); + source.write_region(bounds(), &pixels); + source +} + +fn warp(points: &[[f64; 2]], size: f64, pressure: f64) -> LiquifyStroke { + let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, size); + stroke.pressure = pressure; + stroke.points = points.iter().map(|point| point.to_vec()).collect(); + stroke +} + +fn freeze_left() -> LiquifyStroke { + let mut stroke = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0); + stroke.amount = Some(1.0); + stroke.points = vec![vec![192.0, -32.0], vec![256.0, -32.0], vec![256.0, 96.0], vec![192.0, 96.0]]; + stroke +} + +fn check_frozen_output(prior_warp: bool) { + for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let source = source(mode, sample); + for cell in [2.5, 4.0] { + let mut strokes = Vec::new(); + if prior_warp { + strokes.push(warp(&[[238.0, 20.0], [261.0, 28.0]], 64.0, 55.0)); + } + strokes.push(freeze_left()); + let mut field = LiquifyField::from_strokes(bounds(), cell, &strokes); + // #8012 establishes the bilinear mapping when the mask is applied. + // This test checks later edits, not exact pre-mask cubic preservation. + let before = apply_liquify(&source, &field); + assert_eq!(before.format(), source.format()); + assert_eq!(before == source, !prior_warp, "the fixture must retain an earlier warp and keep a fresh mask-only operation unchanged"); + strokes.push(warp(&[[268.0, 32.0], [252.0, 32.0]], 48.0, 80.0)); + field.apply_stroke(&strokes[strokes.len() - 1]); + let after = apply_liquify(&source, &field); + assert_eq!(after.format(), source.format()); + + let (mut protected, mut fractional_alpha, mut unfrozen_changes) = (0, 0, 0); + let mut frozen_warped = 0; + for y in bounds().y0..bounds().y1 { + for x in bounds().x0..bounds().x1 { + let pixel = before.pixel(x, y); + if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 1.0 { + protected += 1; + frozen_warped += usize::from(pixel != source.pixel(x, y)); + let alpha = pixel[mode.color_channels()]; + fractional_alpha += usize::from(alpha > 0.0 && alpha < 1.0); + // An unchanged F32 pixel can round on premultiply/unpremultiply. + // Integer-depth pixels must remain exactly equal. + let tolerance = if sample == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 }; + for (channel, (actual, expected)) in after.pixel(x, y).iter().zip(&pixel).enumerate() { + assert!( + (actual - expected).abs() <= tolerance, + "{mode:?}/{sample:?}, mesh {cell}, prior warp {prior_warp}: frozen channel {channel} changed at {x},{y}: {expected} -> {actual}" + ); + } + } else if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 0.0 { + unfrozen_changes += usize::from(after.pixel(x, y) != pixel); + } + } + } + assert!(protected > 7_000 && fractional_alpha > 3_000, "the fixture must cover frozen pixels with fractional alpha"); + assert_eq!(frozen_warped > 0, prior_warp, "the fixture must retain the earlier warp inside the fully frozen region"); + assert!(unfrozen_changes > 0, "the adjacent unfrozen region must still move"); + let replay = LiquifyField::from_strokes(bounds(), cell, &strokes); + assert!(field == replay, "stroke replay must reproduce the field exactly"); + assert!(apply_liquify(&source, &replay) == after, "stroke replay must reproduce the final pixels exactly"); + } + } + } +} + +#[test] +fn fresh_frozen_output_preserves_color_and_alpha_across_formats() { + check_frozen_output(false); +} + +#[test] +fn existing_frozen_mapping_survives_adjacent_warps_across_formats() { + check_frozen_output(true); +} diff --git a/crates/algo/tests/it/liquify_source_footprint.rs b/crates/algo/tests/it/liquify_source_footprint.rs new file mode 100644 index 0000000000..759567827f --- /dev/null +++ b/crates/algo/tests/it/liquify_source_footprint.rs @@ -0,0 +1,108 @@ +//! Liquify source reads must depend on the mapping, not the output tile phase. +use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// Keeps the document size fixed while moving its edges relative to global tiles. +fn bounds(shift: i32) -> Rect { + Rect::new(shift, 0, shift + 1024, 512) +} + +/// Builds translated copies of RGB ramps and alternating columns so missing +/// interpolation taps change the sampled colors. +fn source(sample: SampleType, shift: i32) -> Surface { + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, false)); + let mut row = vec![0.0; 1024 * 3]; + for y in 0..512 { + for (x, pixel) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { + *pixel = [x as f32 / 1024.0, (x % 2) as f32, y as f32 / 512.0]; + } + surface.write_region(Rect::new(shift, y, shift + 1024, y + 1), &row); + } + surface +} + +/// Creates matching brush paths in document-relative coordinates, optionally +/// beside a frozen region that gives the displacement field a sharp boundary. +fn strokes(shift: i32, masked: bool) -> Vec { + let offset = f64::from(shift); + let mut strokes = Vec::new(); + if masked { + let mut freeze = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0); + freeze.amount = Some(1.0); + freeze.points = vec![vec![offset, 0.0], vec![offset + 508.0, 0.0], vec![offset + 508.0, 512.0], vec![offset, 512.0]]; + strokes.push(freeze); + } + let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 512.0); + warp.density = 100.0; + warp.points = vec![vec![offset + 512.0, 256.5], vec![offset + 499.2, 256.5]]; + strokes.extend(std::iter::repeat_n(warp, 8)); + for stroke in &strokes { + stroke.validate().expect("the fixture uses supported brush parameters"); + } + strokes +} + +/// Checks that a half-tile translation preserves editable output across sample +/// depths and mesh sizes when image content and brush paths move together. +fn check_tile_translation(masked: bool) { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let original = source(sample, 0); + let translated = source(sample, 128); + for cell in [2.0, 4.0] { + let field = LiquifyField::from_strokes(bounds(0), cell, &strokes(0, masked)); + let shifted_field = LiquifyField::from_strokes(bounds(128), cell, &strokes(128, masked)); + if masked { + assert!(field.d == shifted_field.d, "translation must preserve the nodal displacement values"); + } + // The reported pixels lie entirely on the editable side of the mask. + assert_eq!(field.freeze_at(509.5, 220.5), 0.0); + assert_eq!(shifted_field.freeze_at(637.5, 220.5), 0.0); + let output = apply_liquify(&original, &field); + let shifted_output = apply_liquify(&translated, &shifted_field); + assert_ne!(output.pixel(509, 220), original.pixel(509, 220), "the fixture must actually warp the inspected strip"); + let mut worst = 0.0f32; + // Translation changes which output tile contains this strip, while + // preserving the relative source image, brush path and document edges. + for y in 220..290 { + for x in 509..512 { + let a = output.pixel(x, y); + let b = shifted_output.pixel(x + 128, y); + for (a, b) in a.iter().zip(&b) { + worst = worst.max((a - b).abs()); + } + } + } + // Coordinate translation can perturb the last few F32 arithmetic bits. + // This is far smaller than the clipped source-stencil error. + let tolerance = if sample == SampleType::F32 { 2e-6 } else { 0.0 }; + assert!(worst <= tolerance, "{sample:?}, meshSize {cell}, masked={masked}: moving the document by half a tile changed output by {worst}"); + } + } +} + +#[test] +fn cubic_source_footprint_is_independent_of_output_tile_phase() { + check_tile_translation(true); +} + +#[test] +fn ordinary_unmasked_warp_is_independent_of_output_tile_phase() { + check_tile_translation(false); +} + +#[test] +fn source_reads_still_clamp_at_the_document_edge() { + let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 256.0); + warp.density = 100.0; + warp.points = vec![vec![1008.0, 256.5], vec![944.0, 256.5]]; + warp.validate().expect("the edge fixture uses supported brush parameters"); + let field = LiquifyField::from_strokes(bounds(0), 4.0, &[warp]); + assert!(field.sample(1023.5, 256.5)[0] > 4.0, "the fixture must sample beyond the right document edge"); + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let original = source(sample, 0); + let output = apply_liquify(&original, &field); + assert_eq!(output.pixel(1023, 256), original.pixel(1023, 256), "{sample:?}: an out-of-document source read must repeat the true document edge"); + } +} diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs new file mode 100644 index 0000000000..e611f93cb9 --- /dev/null +++ b/crates/algo/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, linked into one test binary. + +mod liquify_freeze_formats; +mod liquify_source_footprint; +mod retouch_blur_exact; +mod warp_clipped; diff --git a/crates/algo/tests/retouch_blur_exact.rs b/crates/algo/tests/it/retouch_blur_exact.rs similarity index 100% rename from crates/algo/tests/retouch_blur_exact.rs rename to crates/algo/tests/it/retouch_blur_exact.rs diff --git a/crates/algo/tests/warp_clipped.rs b/crates/algo/tests/it/warp_clipped.rs similarity index 100% rename from crates/algo/tests/warp_clipped.rs rename to crates/algo/tests/it/warp_clipped.rs diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index f5e88b3591..13ab9c527b 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -10,7 +10,6 @@ rust-version.workspace = true workspace = true [dependencies] -photocraft-geom = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } photocraft-codecs = { workspace = true } @@ -22,12 +21,15 @@ serde_json = { workspace = true } thiserror = { workspace = true } rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } -base64 = "0.22" +base64 = "0.23" schemars = "1" getrandom = { workspace = true } cap-std = "4" +clap = { workspace = true } [dev-dependencies] +# Synthetic camera raws (#7789). +photocraft-raw = { workspace = true, features = ["testgen"] } rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } diff --git a/crates/automation/src/args.rs b/crates/automation/src/args.rs new file mode 100644 index 0000000000..5c6dd666a7 --- /dev/null +++ b/crates/automation/src/args.rs @@ -0,0 +1,123 @@ +//! The control and automation launch options, as clap types: `photocraft-cli mcp` and `serve` +//! flatten them, and so does the desktop app (#7071), so each flag and its environment variable +//! are declared once. A flag wins over its variable. +//! +//! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset, +//! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from. +//! Check the token pair with [`conflict`] only where a token is used, so a stale exported pair +//! doesn't stop a launch that needs none. + +use std::ffi::OsString; +use std::path::PathBuf; + +use clap::builder::NonEmptyStringValueParser; +use clap::error::{ContextKind, ContextValue, ErrorKind}; +use clap::parser::ValueSource; +use clap::{Arg, ArgMatches, Args, Command}; + +use crate::{AuthorizedWorkspace, AutomationError}; + +/// The control token, for a server or for a client of one. +#[derive(Args)] +pub struct ControlArgs { + /// The control token (64 hex digits) + #[arg(long, value_name = "64-HEX", env = "PHOTOCRAFT_CONTROL_TOKEN", hide_env_values = true, value_parser = NonEmptyStringValueParser::new())] + pub control_token: Option, + /// A file holding the control token, used instead of --control-token (a server creates it + /// when missing) + #[arg(long, value_name = "PATH", env = "PHOTOCRAFT_CONTROL_TOKEN_FILE")] + pub control_token_file: Option, +} + +/// The folders automation may touch. Without a root, that direction of access is denied. No +/// variable stands in for them here: one exported for the desktop app must not widen headless +/// `photocraft-cli` access (#1551); the desktop app adds its own. +#[derive(Args)] +pub struct AutomationArgs { + /// The folder automation may read from (none without it) + #[arg(long, value_name = "DIR")] + pub automation_read_root: Option, + /// The folder automation may write to (none without it) + #[arg(long, value_name = "DIR")] + pub automation_write_root: Option, +} + +impl AutomationArgs { + /// The roots, opened as capabilities. + pub fn workspace(&self) -> Result { + AuthorizedWorkspace::new(self.automation_read_root.as_deref(), self.automation_write_root.as_deref()) + } +} + +/// A control port, for clap's `value_parser`: `photocraft --control`, `photocraft-cli serve --port` +/// and PHOTOCRAFT_CONTROL_PORT, which may carry spaces. +pub fn control_port(s: &str) -> Result { + s.trim().parse().map_err(|_| NotAPort) +} + +/// A [`control_port`] value that isn't a port. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[error("expected a port, 0-65535")] +pub struct NotAPort; + +/// For `Command::mut_args`: an empty or blank variable counts as unset, so `PHOTOCRAFT_CONTROL_PORT=` +/// turns a fallback off without `unset` (#2491). clap would pass the empty value to the parser. +pub fn blank_env_is_unset(arg: Arg) -> Arg { + let blank = arg.get_env().and_then(std::env::var_os).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty())); + if blank { arg.env(None::<&str>) } else { arg } +} + +/// clap names the flag when a value from its variable is bad or conflicts with another, as if the +/// flag had been given: name the variable instead when the flag isn't among `args` (without the +/// binary name; a first one naming a subcommand selects whose arguments to look in) (#2491). +pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error { + // `--flag value` or `--flag=value`. + let given = |long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('=')); + let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd); + for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] { + let Some(ContextValue::String(shown)) = error.get(kind) else { continue }; + let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue }; + let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue }; + if !given(long) { + error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned())); + } + } + error +} + +/// clap's either/or usage error when `m`, parsed by `cmd`, has both `a` and `b`, each named as it was +/// given: the flag, or its variable. For a conflict that depends on the mode, such as the token pair +/// (only a server or bridge reads a token), which clap's `conflicts_with` can't express. +pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(), clap::Error> { + if m.value_source(a).is_none() || m.value_source(b).is_none() { + return Ok(()); + } + let shown = |id: &str| { + let arg = cmd.get_arguments().find(|arg| arg.get_id() == id); + match (m.value_source(id), arg.and_then(Arg::get_env)) { + (Some(ValueSource::EnvVariable), Some(var)) => var.to_string_lossy().into_owned(), + _ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()), + } + }; + let message = format!("the argument '{}' cannot be used with '{}'", shown(a), shown(b)); + Err(cmd.error(ErrorKind::ArgumentConflict, message)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_port_accepts_valid_numbers() { + assert_eq!(control_port("50494"), Ok(50494)); + assert_eq!(control_port(" 8080 "), Ok(8080)); + assert_eq!(control_port("0"), Ok(0)); + } + + #[test] + fn control_port_rejects_anything_else() { + for bad in ["78787", "-1", "", "x"] { + assert_eq!(control_port(bad), Err(NotAPort), "{bad:?}"); + } + } +} diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index cad8d233fa..6722c5c00b 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -100,6 +100,8 @@ impl Headless { files::open_bytes(name, &bytes)? } }; + // Preferences › Raw Defaults › Resolution, as the app's File › Open applies it. + file_cmds::raw_defaults(&self.session, &o.warnings, &mut o.document); let path = match file_cmds::template_name(&self.session, requested) { Some(untitled) => { o.document.name = untitled; @@ -150,6 +152,28 @@ impl Headless { }) } + /// Replace a smart object's contents with a file from the read root: the capability-scoped + /// `layer.smartObjects.replaceContents`, which takes an ambient `path` and is refused in + /// automation (#8061). `params` is the command's own (`layer`). + pub fn replace_smart_object(&mut self, index: Option, path: &str, params: &Value) -> Result { + self.on_doc(index, |this| { + let mut opts = params.as_object().cloned().unwrap_or_default(); + opts.insert("path".into(), json!(path)); + if matches!(&this.filesystem, Filesystem::TrustedLocal) { + return this.command_run("layer.smartObjects.replaceContents", Value::Object(opts)); + } + let bytes = match &this.filesystem { + Filesystem::Workspace(workspace) => workspace.read(path)?, + _ => { + return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())); + } + }; + let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path); + this.sync_jobs(); + Ok(photocraft_engine::smart_cmds::replace_contents_from_bytes(&mut this.session, &Value::Object(opts), name, bytes)?) + }) + } + /// Export each exported layer of a document as its own file beneath the write root: the /// capability-scoped `file.export.layersToFiles`, which takes an ambient `dir` and is /// therefore refused in automation (#3216). The bytes come from the command's own core, so @@ -169,10 +193,16 @@ impl Headless { _ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), }; let mut outputs = workspace.export_sequence(dir)?; - let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| { + let report = file_cmds::export_layers_report_with(&this.session, params, |name, bytes| { outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())) }); - exported(dir, written, &mut outputs) + let (written, skipped) = match report { + Ok(report) => (Ok(report.files), report.skipped), + Err(error) => (Err(error), Vec::new()), + }; + let mut result = exported(dir, written, &mut outputs)?; + result["skipped"] = json!(skipped); + Ok(result) }) } @@ -259,7 +289,10 @@ impl Headless { files::save(&doc, &target, format, opts, Some(writer))? } Filesystem::Workspace(workspace) => { - let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; + // Only the target is granted: a linked smart object's stored path is document + // data, so PSD export writes that layer as pixels instead of embedding the file. + let opts = ExportOptions { read_linked_files: false, ..opts.clone() }; + let (bytes, warnings) = files::save_bytes(&doc, target_text, format, &opts)?; workspace.write(target_text, &bytes)?; warnings } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1e7049d0d..b4691723b9 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -16,6 +16,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod args; pub mod bridge; pub mod budgets; pub mod files; diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 83d91238f4..b98094eb5d 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -10,6 +10,8 @@ //! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`, //! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope, //! the counterpart of the refused ambient `file.placeEmbedded`), +//! `doc.replaceSmartObject {path, layer?, index?}` (#8061: the same scope for +//! `layer.smartObjects.replaceContents`), //! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`, //! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`, //! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export @@ -22,7 +24,9 @@ use std::io::{BufRead, Write}; use std::net::TcpListener; use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; use base64::Engine as _; use serde_json::{Value, json}; @@ -43,6 +47,7 @@ pub const METHODS: &[&str] = &[ "session.list", "doc.open", "doc.place", + "doc.replaceSmartObject", "doc.new", "doc.save", "doc.exportLayers", @@ -108,6 +113,13 @@ impl Headless { opts.remove("index"); self.place(index_of(&p)?, path, &Value::Object(opts)) } + "doc.replaceSmartObject" => { + let path = str_of(&p, "path").ok_or_else(|| bad("doc.replaceSmartObject needs `path`"))?; + let mut opts = p.as_object().cloned().unwrap_or_default(); + opts.remove("path"); + opts.remove("index"); + self.replace_smart_object(index_of(&p)?, path, &Value::Object(opts)) + } "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()))?; @@ -315,32 +327,67 @@ fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: } } +/// Headless TCP daemons with no clients should eventually release their listening port (#3682). +const TCP_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60); + /// Serve authenticated JSON lines on a loopback TCP address with one shared session. -/// Refuses non-loopback addresses and caps active connections and request bytes. +/// Refuses non-loopback addresses, caps connections and request bytes, and closes the listener +/// after an idle period with no connections. Active clients are never evicted by this timer. pub fn serve_tcp(addr: &str, h: Arc>, token: String, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> { + serve_tcp_until_idle(addr, h, token, ready, TCP_IDLE_TIMEOUT) +} + +fn serve_tcp_until_idle( + addr: &str, + h: Arc>, + token: String, + ready: impl FnOnce(std::net::SocketAddr), + idle: Duration, +) -> Result<(), AutomationError> { let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?; let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?; if !local.ip().is_loopback() { return Err(bad(format!("{addr} is not a loopback address"))); } + listener.set_nonblocking(true).map_err(|e| AutomationError::Io(e.to_string()))?; ready(local); let limiter = ConnectionLimiter::new(MAX_CONNECTIONS); let token = Arc::new(token); - for stream in listener.incoming() { - let Ok(mut stream) = stream else { continue }; - let Some(permit) = limiter.try_acquire() else { - let _ = configure_stream(&stream); - let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"})); - continue; - }; - let h = h.clone(); - let token = Arc::clone(&token); - std::thread::spawn(move || { - let _permit = permit; - let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE); - }); + let active = Arc::new(AtomicUsize::new(0)); + let mut last_active = Instant::now(); + loop { + match listener.accept() { + Ok((mut stream, _)) => { + last_active = Instant::now(); + let Some(permit) = limiter.try_acquire() else { + let _ = configure_stream(&stream); + let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"})); + continue; + }; + // The listener is nonblocking; individual requests still use blocking socket I/O. + let _ = stream.set_nonblocking(false); + let h = h.clone(); + let token = Arc::clone(&token); + let active = Arc::clone(&active); + active.fetch_add(1, Ordering::AcqRel); + std::thread::spawn(move || { + let _permit = permit; + let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE); + active.fetch_sub(1, Ordering::AcqRel); + }); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if active.load(Ordering::Acquire) != 0 { + last_active = Instant::now(); + } else if last_active.elapsed() >= idle { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::ConnectionAborted) => {} + Err(e) => return Err(AutomationError::Io(e.to_string())), + } } - Ok(()) } #[cfg(test)] @@ -351,6 +398,19 @@ mod tests { Mutex::new(Headless::trusted_local()) } + #[test] + fn document_activation_reply_survives_engine_rpc() { + let h = session(); + let mut g = h.lock().unwrap(); + for _ in 0..2 { + g.handle("engine.execute", json!({"command": "file.new", "params": {"width": 4, "height": 4}})).unwrap(); + } + let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"offset": -1}})).unwrap(); + assert_eq!(reply, json!({"document": 0})); + let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"document": 1}})).unwrap(); + assert_eq!(reply, json!({"document": 1})); + } + #[test] fn execute_reports_invalid_filter_parameters_without_changing_history() { let h = session(); @@ -711,6 +771,129 @@ mod tests { std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn doc_replace_smart_object_reads_through_the_read_root_and_never_outside_it() { + let base = std::env::temp_dir().join(format!("pc-doc-replace-so-{}", std::process::id())); + let root = base.join("workspace"); + let outside = base.join("outside"); + for dir in [&root, &outside] { + std::fs::create_dir_all(dir).unwrap(); + } + let png = |w: u32, h: u32, v: u8| { + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, vec![v; (w * h * 3) as usize]).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap() + }; + std::fs::write(root.join("logo.png"), png(8, 6, 42)).unwrap(); + std::fs::write(root.join("red.png"), png(8, 6, 200)).unwrap(); + std::fs::write(outside.join("red.png"), png(8, 6, 200)).unwrap(); + let absolute = outside.join("red.png").to_string_lossy().into_owned(); + + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap(); + let layer = h.handle("doc.place", json!({"path": "logo.png"})).unwrap()["layer"].clone(); + // The smart object's file name and the composite at its centre: what Replace Contents changes. + let contents = |h: &mut Headless| { + let inspected = h.handle("doc.inspect", json!({})).unwrap(); + let layers = inspected["layers"].as_array().unwrap(); + let so = layers.iter().find(|l| l["id"] == layer).unwrap_or_else(|| panic!("{inspected}")); + let name = so["smartSource"]["fileName"].as_str().unwrap_or_default().to_owned(); + let rendered = h.handle("doc.render", json!({"maxSide": 0})).unwrap(); + let png = base64::engine::general_purpose::STANDARD.decode(rendered["base64"].as_str().unwrap()).unwrap(); + let img = photocraft_codecs::decode(&png).unwrap(); + let (w, ht) = img.dimensions(); + let data = img.as_u8().unwrap(); + let channels = data.len() / (w * ht) as usize; + (name, data[((ht / 2 * w + w / 2) as usize) * channels]) + }; + assert_eq!(contents(&mut h), ("logo.png".to_owned(), 42), "the placed contents"); + let replaced = h.handle("doc.replaceSmartObject", json!({"path": "red.png", "layer": layer})).unwrap(); + assert_eq!(replaced["layer"], layer, "{replaced}"); + assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "the contents were replaced"); + h.handle("engine.execute", json!({"command": "edit.undo"})).unwrap(); + assert_eq!(contents(&mut h), ("logo.png".to_owned(), 42), "one undo step restores the old contents"); + h.handle("engine.execute", json!({"command": "edit.redo"})).unwrap(); + assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "redo replaces them again"); + // Outside the read root, a bad layer, a missing path or a file that is no image: refused, nothing changed. + for (params, reason) in [ + (json!({"path": "../outside/red.png"}), "parent traversal"), + (json!({"path": absolute}), "must be inside the automation root"), + (json!({"path": "logo.png", "layer": 999_999}), "no such layer"), + (json!({}), "needs `path`"), + ] { + let refused = h.handle("doc.replaceSmartObject", params).unwrap_err().to_string(); + assert!(refused.contains(reason), "{refused}"); + assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "nothing changed: {refused}"); + } + std::fs::write(root.join("junk.png"), b"not an image").unwrap(); + assert!(h.handle("doc.replaceSmartObject", json!({"path": "junk.png", "layer": layer})).is_err()); + assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "a file that is no image changes nothing"); + // The ambient command keeps its refusal. + let ambient = h.handle("engine.execute", json!({"command": "layer.smartObjects.replaceContents", "params": {"path": "red.png"}})); + assert!(ambient.unwrap_err().to_string().contains("ambient filesystem paths")); + // Trusted local keeps the ambient command. + let mut t = Headless::trusted_local(); + t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap(); + let tl = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap()["layer"].clone(); + let trusted = t.handle("doc.replaceSmartObject", json!({"path": root.join("red.png").to_string_lossy(), "layer": tl})).unwrap(); + assert_eq!(trusted["layer"], tl, "{trusted}"); + drop(t); + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn doc_export_layers_reports_skips_in_workspace_and_trusted_sessions() { + for trusted in [false, true] { + let base = std::env::temp_dir().join(format!("pc-export-skips-{}-{trusted}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let mut h = + if trusted { Headless::trusted_local() } else { Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&base), Some(&base)).unwrap()) }; + h.handle("doc.new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).unwrap(); + let adjustment = h.handle("engine.execute", json!({"command": "layer.newAdjustmentLayer.levels"})).unwrap()["layer"].clone(); + h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"name": "Levels"}})).unwrap(); + let hidden = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Hidden"}})).unwrap()["layer"].clone(); + h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"visible": false}})).unwrap(); + // Exercise the actual JSON-lines serve envelope, not only direct dispatch. + let h = Mutex::new(h); + let out_dir = base.join("out").to_string_lossy().into_owned(); + let request = json!({"id": 8491, "method": "doc.exportLayers", "params": {"dir": out_dir}}).to_string() + "\n"; + let mut output = Vec::new(); + serve_lines(&h, request.as_bytes(), &mut output).unwrap(); + let reply: Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(reply["id"], 8491); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["files"], json!([format!("{out_dir}/card_0001_Background.png")])); + if !trusted { + assert_eq!(reply["result"]["count"], 1); + } + assert_eq!( + reply["result"]["skipped"], + json!([ + {"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"}, + {"layer": adjustment, "name": "Levels", "reason": "adjustment layer has no pixels of its own"} + ]) + ); + assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 1); + let mut h = h.into_inner().unwrap(); + let all_dir = base.join("all").to_string_lossy().into_owned(); + let all = h.export_layers(None, &all_dir, &json!({"visibleOnly": false})).unwrap(); + assert_eq!(all["files"].as_array().unwrap().len(), 2); + assert_eq!(all["skipped"].as_array().unwrap().len(), 1); + // Workspace no-overwrite errors still fail and remove only newly created files. + if !trusted { + let collision = base.join("collision"); + std::fs::create_dir_all(&collision).unwrap(); + std::fs::write(collision.join("card_0002_Background.png"), b"keep").unwrap(); + assert!(h.export_layers(None, &collision.to_string_lossy(), &json!({"visibleOnly": false})).is_err()); + assert_eq!(std::fs::read_dir(&collision).unwrap().count(), 1); + assert_eq!(std::fs::read(collision.join("card_0002_Background.png")).unwrap(), b"keep"); + } + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + } + #[test] fn doc_export_layers_writes_only_beneath_the_write_root() { let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id())); @@ -787,7 +970,7 @@ mod tests { } #[test] - fn indexed_place_and_data_set_methods_keep_the_active_document() { + fn indexed_place_replace_and_data_set_methods_keep_the_active_document() { let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id())); let root = base.join("workspace"); std::fs::create_dir_all(&root).unwrap(); @@ -804,6 +987,9 @@ mod tests { let steps = [ ("doc.place", json!({"path": "logo.png", "index": 1}), true), + // Document 1's active layer is now the placed Smart Object. + ("doc.replaceSmartObject", json!({"path": "logo.png", "index": 1}), true), + ("doc.replaceSmartObject", json!({"path": "missing.png", "index": 1}), false), ("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true), ("doc.exportLayers", json!({"dir": "layers", "index": 1}), true), ("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true), @@ -823,6 +1009,145 @@ mod tests { std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn doc_export_data_sets_judges_pixel_replacement_paths_like_apply() { + let base = std::env::temp_dir().join(format!("pc-doc-data-set-pixels-{}", std::process::id())); + let root = base.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let red = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(16)).unwrap(); + let photo = base.join("photo.png"); + std::fs::write(&photo, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap(); + let layer = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {}})).unwrap()["layer"].clone(); + h.handle( + "engine.execute", + json!({"command": "image.variables.define", "params": {"defs": [{"name": "photo", "layer": layer, "type": "pixelReplacement"}]}}), + ) + .unwrap(); + // Storing the path is allowed; reading it is what the gate judges. + h.handle( + "engine.execute", + json!({"command": "image.variables.dataSets", "params": {"dataSets": [ + {"name": "swap", "values": [{"variable": "photo", "kind": "pixels", "value": photo.to_string_lossy()}]}, + ]}}), + ) + .unwrap(); + let applied = h.handle("engine.execute", json!({"command": "image.applyDataSet", "params": {"name": "swap"}})).unwrap_err().to_string(); + assert!(applied.contains("ambient filesystem paths"), "{applied}"); + let sets = root.join("sets"); + let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap_err().to_string(); + assert!(exported.contains("ambient filesystem paths"), "{exported}"); + assert_eq!(std::fs::read_dir(&sets).map_or(0, Iterator::count), 0, "nothing of the refused batch is written"); + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn workspace_psd_saves_embed_no_file_a_linked_smart_object_names() { + use photocraft_engine::doc::{LayerContent, SmartSource}; + + // An image PhotoCraft can open (other linked files are never embedded): an embedded copy + // carries these exact bytes, while pixels written for the layer don't. + let image = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, (0..192u8).collect()).unwrap(); + let marker = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let has_marker = |bytes: &[u8]| bytes.windows(marker.len()).any(|w| w == marker.as_slice()); + let base = std::env::temp_dir().join(format!("pc-psd-linked-embed-{}", std::process::id())); + let root = base.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let outside = base.join("outside.png"); + std::fs::write(&outside, &marker).unwrap(); + // A document whose smart object names a file outside the roots as its source. + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let state = session.active_mut().unwrap(); + let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") }; + smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() }; + std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap(); + // A trusted export embeds the linked file, as before. + assert!(has_marker(&photocraft_io::export(&state.doc, "psd", &Default::default()).unwrap().bytes)); + + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap(); + let saved = h.handle("doc.save", json!({"path": "out.psd"})).unwrap(); + let warnings = saved["warnings"].to_string(); + assert!(warnings.contains("written as pixels"), "{warnings}"); + assert!(!has_marker(&std::fs::read(root.join("out.psd")).unwrap()), "the linked file outside the roots is not embedded"); + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn allowed_filters_do_not_rerender_a_smart_object_from_its_linked_file() { + use photocraft_engine::doc::{LayerContent, SmartSource}; + + let base = std::env::temp_dir().join(format!("pc-linked-rerender-{}", std::process::id())); + let root = base.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let red = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(64)).unwrap(); + let outside = base.join("red.png"); + std::fs::write(&outside, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + // A document whose white smart object names a file outside the roots as its source. + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let state = session.active_mut().unwrap(); + let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") }; + smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() }; + std::fs::write(root.join("p.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap(); + + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.open", json!({"path": "p.pcraft"})).unwrap(); + h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 0.5}})).unwrap(); + let doc = &h.session.active().unwrap().doc; + let LayerContent::Smart(smart) = &doc.layers[0].content else { panic!("smart object") }; + assert_eq!(smart.smart_filters.len(), 1, "the filter is still recorded"); + assert_eq!(smart.cache.as_ref().unwrap().rgba(4, 4), [1.0, 1.0, 1.0, 1.0], "the linked file outside the roots is not read"); + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn workspace_layer_exports_keep_linked_smart_objects_as_pixels() { + use photocraft_engine::doc::{LayerContent, SmartSource}; + + const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0002"; + let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER); + let base = std::env::temp_dir().join(format!("pc-export-layers-linked-{}", std::process::id())); + let root = base.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let outside = base.join("outside.txt"); + std::fs::write(&outside, MARKER).unwrap(); + // A document whose smart object names a file outside the roots as its source. + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let state = session.active_mut().unwrap(); + let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") }; + smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() }; + std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap(); + + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap(); + let out = root.join("layers"); + for format in ["psd", "psb"] { + let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": format})).unwrap(); + assert_eq!(exported["count"], 1, "{exported}"); + } + let files: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).collect(); + assert_eq!(files.len(), 2, "{files:?}"); + for file in files { + assert!(!has_marker(&std::fs::read(&file).unwrap()), "{} embeds no file outside the roots", file.display()); + } + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn trusted_local_render_keeps_full_size_behavior() { let mut h = Headless::trusted_local(); @@ -861,6 +1186,38 @@ mod tests { assert!(h.handle("methods", Value::Null).is_ok()); } + #[test] + fn idle_tcp_daemon_releases_its_port_but_keeps_active_clients() { + use std::io::BufReader; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let h = Arc::new(Mutex::new(Headless::new())); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let result = serve_tcp_until_idle("127.0.0.1:0", h, TOKEN.to_owned(), move |addr| ready_tx.send(addr).unwrap(), Duration::from_millis(200)); + done_tx.send(result).unwrap(); + }); + let addr = ready_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let mut client = std::net::TcpStream::connect(addr).unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut reader = BufReader::new(client.try_clone().unwrap()); + writeln!(client, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap(); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let auth: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(auth["result"]["authenticated"], true); + // The port stays owned well past the idle limit while a client is connected. + assert!( + matches!(done_rx.recv_timeout(Duration::from_millis(450)), Err(std::sync::mpsc::RecvTimeoutError::Timeout)), + "daemon exited with a live client" + ); + drop(reader); + drop(client); + assert!(done_rx.recv_timeout(Duration::from_secs(3)).unwrap().is_ok(), "idle daemon did not exit cleanly"); + server.join().unwrap(); + assert!(TcpListener::bind(addr).is_ok(), "idle listener still owns its port"); + } + #[test] fn tcp_serves_loopback() { use std::io::{BufReader, Write as _}; diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 0735511f20..7fff71c67a 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -2,7 +2,7 @@ use std::fs::OpenOptions; use std::io::{BufRead, Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; @@ -308,13 +308,6 @@ impl Read for AuthDeadlineStream { } } -/// Environment-aware token inputs shared by the desktop app and CLI. -pub fn token_inputs(supplied: Option, token_file: Option) -> (Option, Option) { - let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok()); - let token_file = token_file.or_else(|| std::env::var_os("PHOTOCRAFT_CONTROL_TOKEN_FILE").map(PathBuf::from)); - (supplied, token_file) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 46905daf38..61a6454e79 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -71,6 +71,20 @@ pub struct PlaceParams { pub center: Option<[f64; 2]>, } +#[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReplaceSmartObjectParams { + /// Path beneath the configured automation read root: relative (forward slashes) or absolute. + pub path: String, + /// Headless mode only: document index from `session_list` (default: the active document). + /// Bridge mode rejects `index`. + #[serde(default)] + pub index: Option, + /// The Smart Object layer: an id or a name (default: the active layer). + #[serde(default)] + pub layer: Option, +} + #[derive(Debug, Default, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ExportLayersParams { @@ -315,7 +329,7 @@ pub struct MenuParams { pub struct UiSetParams { /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, - /// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, + /// vectorMaskTarget, filterMaskTarget (paint the active smart object's filter mask), selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab, /// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it), /// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one @@ -336,7 +350,14 @@ pub struct UiSetParams { /// "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). Other fields are an error. + /// 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", + /// "selectedLayer" or "adjustmentComposite") for the Histogram panel), histogramRange ([from, + /// to] levels 0..255 whose Count and Percentile the panel shows, or null) and histogramRefresh + /// (true: an Uncached Refresh); `ui.inspect` reports them and the statistics under + /// `histogram`. Other fields are an error. pub fields: Value, } @@ -412,6 +433,14 @@ fn strict_node(v: &mut Value, defs: &serde_json::Map, depth: usiz } } +/// Both the bare engine list and older bridge wrappers own their rows already. +fn into_command_rows(mut reply: Value) -> Vec { + match reply { + Value::Array(rows) => rows, + _ => reply.get_mut("commands").and_then(Value::as_array_mut).map(std::mem::take).unwrap_or_default(), + } +} + fn ok_json(v: &Value) -> CallToolResult { let text = match json_bytes(v).and_then(|bytes| String::from_utf8(bytes).map_err(|error| AutomationError::Other(error.to_string()))) { Ok(text) => text, @@ -637,7 +666,33 @@ impl PhotocraftMcp { } #[tool( - description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite." + description = "Replace a Smart Object's contents with the file at `path` beneath the read root, keeping its transform and filters (the capability-scoped Replace Contents). `layer` is the Smart Object's id or name (default: the active layer)." + )] + async fn doc_replace_smart_object(&self, Parameters(p): Parameters) -> Result { + let mut opts = serde_json::Map::new(); + if let Some(v) = p.layer { + opts.insert("layer".into(), v); + } + let opts = Value::Object(opts); + // 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() { + o.insert("path".into(), Value::String(p.path.clone())); + } + let (path, index) = (p.path, p.index); + if let Some(r) = self.headless_op(move |h| h.replace_smart_object(index, &path, &opts)).await { + return to_result(r); + } + // The app's command runs on its active document: an `index` it cannot honour is refused + // rather than dropped, or another document's contents would be replaced. + if index.is_some() { + return Ok(fail("`index` is only supported in headless mode; in bridge mode select the document in the app first")); + } + self.run_command("layer.smartObjects.replaceContents".into(), bridge_params, true).await + } + + #[tool( + description = "Export top-level layers as files (groups merged). Returns files and skipped: [{layer, name, reason}] for hidden layers (visible_only) and adjustments with no pixels of their own. Workspace exports resolve beneath the write root, also return count of written files, and reject existing files instead of overwriting them. Trusted-local/bridge exports use the engine command's file access policy." )] async fn doc_export_layers(&self, Parameters(p): Parameters) -> Result { let mut opts = serde_json::Map::new(); @@ -827,10 +882,7 @@ impl PhotocraftMcp { }; let needle = p.filter.map(|f| f.to_lowercase()); let enabled_only = p.enabled_only.unwrap_or(false); - let items: Vec = all - .as_array() - .cloned() - .unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default()) + let items: Vec = into_command_rows(all) .into_iter() .filter(|c| { needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)) @@ -1083,6 +1135,29 @@ pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Res mod tests { use super::*; + #[test] + fn command_rows_keep_bare_and_wrapped_replies_compatible() { + let rows = json!([{"id":"first","params":{"nested":[1,2]}}, {"id":"second","enabled":false}]); + assert_eq!(into_command_rows(rows.clone()), rows.as_array().unwrap().clone()); + assert_eq!(into_command_rows(json!({"commands":rows,"other":"ignored"})), rows.as_array().unwrap().clone()); + for reply in [json!(null), json!(false), json!(3), json!("invalid"), json!({}), json!({"commands":null}), json!({"commands":{}}), json!([])] { + assert!(into_command_rows(reply).is_empty()); + } + } + + #[test] + fn command_rows_reuse_the_owned_array_and_nested_strings() { + for wrapped in [false, true] { + let rows = json!([{"id":"first","params":"documentation".repeat(64)}]); + let array = rows.as_array().unwrap().as_ptr(); + let text = rows[0]["params"].as_str().unwrap().as_ptr(); + let reply = if wrapped { Value::Object([("commands".into(), rows)].into_iter().collect()) } else { rows }; + let moved = into_command_rows(reply); + assert_eq!(moved.as_ptr(), array, "wrapped={wrapped}: move the original allocation"); + assert_eq!(moved[0]["params"].as_str().unwrap().as_ptr(), text, "wrapped={wrapped}: retain nested allocations"); + } + } + #[test] fn strict_schema_inlines_refs_and_stops_on_recursive_definitions() { // A definition that refers to itself: inlining stops at a depth instead of looping. diff --git a/crates/automation/src/server/transport.rs b/crates/automation/src/server/transport.rs index 44ba4596ba..82e96b13c3 100644 --- a/crates/automation/src/server/transport.rs +++ b/crates/automation/src/server/transport.rs @@ -7,7 +7,7 @@ use crate::security::MAX_REQUEST_BYTES; /// Read a complete request before allowing any of its bytes to reach rmcp. `lines()` /// buffers without a limit, while forwarding partial input can make a valid JSON prefix /// executable at EOF after an oversized suffix is rejected. -async fn bounded_line(source: &mut BufReader, maximum: usize) -> std::io::Result> { +async fn bounded_line(source: &mut BufReader, maximum: usize) -> std::io::Result>> { let mut bytes = Vec::new(); loop { let available = source.fill_buf().await?; @@ -15,7 +15,7 @@ async fn bounded_line(source: &mut BufReader if bytes.is_empty() { return Ok(None); } - return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + return Ok(Some(bytes)); } let newline = available.iter().position(|&byte| byte == b'\n'); let length = newline.map_or(available.len(), |at| at + 1); @@ -29,7 +29,7 @@ async fn bounded_line(source: &mut BufReader if bytes.last() == Some(&b'\r') { bytes.pop(); } - return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)); + return Ok(Some(bytes)); } } } @@ -48,11 +48,16 @@ impl PhotocraftMcp { let reader = tokio::spawn(async move { let mut lines = BufReader::new(input); while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? { - if line.trim().is_empty() { + if std::str::from_utf8(&line).is_ok_and(|text| text.trim().is_empty()) { continue; } - match serde_json::from_str::(&line) { - Ok(_) => to_service.write_all(format!("{line}\n").as_bytes()).await?, + // Bytes that are not UTF-8 are malformed JSON too: they get the -32700 reply + // like any other bad line instead of ending the session. + match serde_json::from_slice::(&line) { + Ok(_) => { + to_service.write_all(&line).await?; + to_service.write_all(b"\n").await?; + } Err(e) => { let reply = json!({"jsonrpc":"2.0", "id":null, "error":{"code":-32700, "message":format!("parse error: {e}")}}); if errors.send(reply.to_string()).await.is_err() { @@ -99,8 +104,8 @@ mod tests { #[tokio::test] async fn bounded_line_accepts_exact_limit_and_final_line() { let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec())); - assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234")); - assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!")); + assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"1234"[..])); + assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"last!"[..])); assert!(bounded_line(&mut input, 5).await.unwrap().is_none()); let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec())); @@ -143,4 +148,42 @@ mod tests { let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap(); assert_eq!(documents, 0, "the oversized tool call must not run"); } + + async fn next_reply(replies: &mut tokio::io::Lines>) -> Value { + let line = tokio::time::timeout(std::time::Duration::from_secs(5), replies.next_line()).await.expect("a reply in time").unwrap().expect("a reply line"); + serde_json::from_str(&line).unwrap() + } + + #[tokio::test] + async fn a_line_that_is_not_utf8_gets_a_parse_error_and_the_session_keeps_serving() { + let (mut client, input) = tokio::io::duplex(1 << 16); + let (output, replies) = tokio::io::duplex(1 << 16); + let server = PhotocraftMcp::headless(); + let observer = server.clone(); + let session = tokio::spawn(server.serve_io(input, output)); + let mut replies = BufReader::new(replies).lines(); + + let initialize = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "utf8-test", "version": "1"} + }}); + client.write_all(format!("{initialize}\n").as_bytes()).await.unwrap(); + assert_eq!(next_reply(&mut replies).await["id"], 1); + client.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n").await.unwrap(); + + client.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\xff\xfe}\n").await.unwrap(); + let error = next_reply(&mut replies).await; + assert_eq!(error["error"]["code"], -32700, "{error}"); + assert_eq!(error["id"], Value::Null, "{error}"); + + let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_new", "arguments": {"width": 8, "height": 8}}}); + client.write_all(format!("{call}\n").as_bytes()).await.unwrap(); + let reply = next_reply(&mut replies).await; + assert_eq!(reply["id"], 2, "{reply}"); + assert_ne!(reply["result"]["isError"], true, "{reply}"); + let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap(); + assert_eq!(documents, 1, "the call after the bad line must run"); + + drop(client); + session.await.unwrap().unwrap(); + } } diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index efd6d52738..860e78ca97 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -389,18 +389,28 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool { fn preference_uses_ambient_filesystem(path: &str) -> bool { // The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so // judge the first non-empty one; no segment at all is the whole-preferences update. - let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true }; + let mut segments = path.split('.').filter(|segment| !segment.is_empty()); + let Some(section) = segments.next() else { return true }; + // File Handling's LUT folder (#6558) is listed from disk; the rest of that section is not. matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks") + || (section == "fileHandling" && segments.next().is_none_or(|key| key == "lutFolder")) } fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { + "actions.load" + | "brush.presets.importAbr" + | "brush.presets.importImage" + | "gradient.presets.importGrd" + | "plugin.install" + | "swatches.import" + | "style.presets.importAsl" + | "layer.layerStyle.contours.load" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } // With a `path` the file is written there; without one the bytes come back as `data`. - "actions.save" | "swatches.export" => params.get("path").is_some(), + "actions.save" | "swatches.export" | "style.presets.exportAsl" | "layer.layerStyle.contours.save" => params.get("path").is_some(), // Shortcut sets: a .kys read or written by path, or a Summarize page written by path. "edit.keyboardShortcuts" => { params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some()) @@ -802,6 +812,21 @@ mod tests { assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err()); } + // Regression test for #3763: `edit.installedProfiles` scans the OS and user ICC folders but + // takes no path parameter, so automation let it through until it was listed as ambient. + #[test] + fn installed_profiles_listing_is_refused_for_automation() { + for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"})] { + assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err(), "authorize_engine_command allowed it with {params}"); + assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err(), "authorize_desktop_engine_command allowed it with {params}"); + assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err(), "authorize_engine_step allowed it with {params}"); + } + // The sandboxed session refuses it before the engine scans any folder. + let mut headless = crate::Headless::new(); + let refused = headless.command_run("edit.installedProfiles", serde_json::json!({})).unwrap_err(); + assert!(refused.to_string().contains("ambient filesystem paths"), "{refused}"); + } + #[test] fn apply_data_set_is_judged_by_the_values_it_applies() { let mut headless = crate::Headless::new(); @@ -842,6 +867,11 @@ mod tests { ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), ("actions.load", serde_json::json!({"path": "/outside/set.atn"})), ("actions.save", serde_json::json!({"path": "/outside/set.atn"})), + ("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})), + ("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})), + ("style.presets.exportAsl", serde_json::json!({"path": 5})), + ("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})), + ("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})), ] { assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}"); } @@ -854,6 +884,10 @@ mod tests { ("swatches.export", serde_json::json!({"format": "ase"})), ("actions.load", serde_json::json!({"data": "QUJD"})), ("actions.save", serde_json::json!({"set": "S"})), + ("style.presets.importAsl", serde_json::json!({"data": "QUJD"})), + ("style.presets.exportAsl", serde_json::json!({"group": "Basics"})), + ("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})), + ("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})), ] { assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}"); } @@ -868,6 +902,8 @@ mod tests { "historyLog.filePath", ".historyLog.filePath", "plugIns.additionalPluginsFolder", + "fileHandling.lutFolder", + "fileHandling", "scratchDisks.disks", ".", "..historyLog.filePath", @@ -878,6 +914,7 @@ mod tests { authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err() ); assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok()); + assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "fileHandling.autosave", "value": false})).is_ok()); } #[test] @@ -907,6 +944,13 @@ mod tests { 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(); @@ -1058,6 +1102,7 @@ mod tests { | "path.rename" | "path.stroke" | "type.attachToPath" + | "type.fillPath" | "type.detachFromPath" | "select.toWorkPath" | "layer.vectorMask.add" @@ -1104,6 +1149,8 @@ mod tests { ("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})), ("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})), ("plugin.reload", serde_json::json!({"path": "/outside/plugins"})), + ("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})), + ("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})), ("prefs.set", serde_json::json!({"path": "historyLog.filePath", "value": "/outside/log"})), ("prefs.set", serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})), ] { diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/it/agent_tasks.rs similarity index 100% rename from crates/automation/tests/agent_tasks.rs rename to crates/automation/tests/it/agent_tasks.rs diff --git a/crates/automation/tests/it/agent_tasks_extended.rs b/crates/automation/tests/it/agent_tasks_extended.rs new file mode 100644 index 0000000000..4f950ed1a8 --- /dev/null +++ b/crates/automation/tests/it/agent_tasks_extended.rs @@ -0,0 +1,184 @@ +//! M11 acceptance, part two: ten more scripted edit tasks (21–30) over MCP (roadmap M11 DoD). +//! +//! Same contract as `agent_tasks.rs`: each task uses only MCP tools (`doc_*`, `command_run`) the +//! way an agent would, and checks the outcome through `doc_inspect`, `document.pixel` or the +//! exported files, never engine internals. + +use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp}; +use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig}; +use rmcp::service::RunningService; +use rmcp::{ClientHandler, RoleClient, ServiceExt}; +use serde_json::{Value, json}; + +#[derive(Clone, Default)] +struct Client; +impl ClientHandler for Client { + fn get_info(&self) -> ClientConfig { + ClientConfig::default() + } +} + +type Conn = RunningService; +type Server = tokio::task::JoinHandle<()>; + +async fn connect(root: &std::path::Path) -> (Conn, Server) { + let (s, c) = tokio::io::duplex(1 << 20); + let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace"); + let server = tokio::spawn(async move { + if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await { + let _ = running.waiting().await; + } + }); + (Client.serve(c).await.expect("client init"), server) +} + +async fn shutdown(c: Conn, server: Server) { + c.cancel().await.unwrap(); + server.await.unwrap(); +} + +fn text(r: &CallToolResult) -> String { + r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::>().join("\n") +} + +/// Call a tool and parse its JSON result, failing the test on a tool error. +async fn tool(c: &Conn, name: &str, args: Value) -> Value { + let mut p = CallToolRequestParams::new(name.to_owned()); + if let Value::Object(m) = args { + p = p.with_arguments(m); + } + let r = c.call_tool(p).await.expect("call_tool transport"); + assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r)); + serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r))) +} + +async fn run(c: &Conn, id: &str, params: Value) -> Value { + tool(c, "command_run", json!({"id": id, "params": params})).await +} + +async fn inspect(c: &Conn) -> Value { + tool(c, "doc_inspect", json!({})).await +} + +async fn pixel(c: &Conn, x: i32, y: i32) -> Vec { + let v = run(c, "document.pixel", json!({"x": x, "y": y})).await; + let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) }; + arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect() +} + +fn top_layer(doc: &Value) -> &Value { + &doc["layers"][0] +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn close(a: &[f64], b: &[f64], tol: f64) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +/// The next ten acceptance tasks (21–30): fill layers, adjustment layers with masks, grouping, +/// merging, gradients of edits, channel round trips, JPEG export and a native save round trip +/// that preserves a group. +#[tokio::test(flavor = "multi_thread")] +async fn agent_completes_ten_extended_tasks() { + let dir = tmp("tasks3"); + let (c, server) = connect(&dir).await; + + // 21. Paint a red background and cover it with a solid-colour fill layer. + tool(&c, "doc_new", json!({"width": 64, "height": 64, "background": "white"})).await; + run(&c, "select.all", json!({})).await; + run(&c, "edit.fill", json!({"color": "#ff0000"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "layer.newFillLayer.solidColor", json!({"color": "#00ffff"})).await; + assert_eq!(top_layer(&inspect(&c).await)["kind"], "Fill", "task 21"); + let cyan = pixel(&c, 32, 32).await; + assert!(cyan[1] > 0.99 && cyan[2] > 0.99 && cyan[0] < 0.01, "task 21: the fill covers red {cyan:?}"); + + // 22. An Invert adjustment flips it to red; deleting the adjustment restores cyan. + run(&c, "layer.newAdjustmentLayer.invert", json!({})).await; + assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 22"); + let inverted = pixel(&c, 32, 32).await; + assert!(inverted[0] > 0.99 && inverted[1] < 0.01 && inverted[2] < 0.01, "task 22: cyan inverts to red {inverted:?}"); + run(&c, "layer.delete", json!({})).await; + assert!(close(&pixel(&c, 32, 32).await, &cyan, 1e-4), "task 22: deleting the adjustment restores cyan"); + + // 23. Hide a rectangular selection behind the layer's mask. + run(&c, "layer.delete", json!({})).await; // drop the fill so the red background shows + run(&c, "select.rect", json!({"x": 16, "y": 16, "width": 32, "height": 32})).await; + run(&c, "layer.layerMask.hideSelection", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["hasMask"], true, "task 23"); + let hidden = pixel(&c, 32, 32).await; + let kept = pixel(&c, 4, 4).await; + assert!(hidden[3] < 0.01, "task 23: the selection is masked away {hidden:?}"); + assert!(kept[3] > 0.99 && kept[0] > 0.99, "task 23: outside keeps the red {kept:?}"); + run(&c, "layer.layerMask.delete", json!({})).await; + + // 24. Group every layer; the pixels are unchanged. + let before = pixel(&c, 32, 32).await; + run(&c, "select.allLayers", json!({})).await; + run(&c, "layer.groupLayers", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["kind"], "Group", "task 24: {doc}"); + assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 24: grouping is visual only"); + + // 25. Flatten the grouped document to one layer. + run(&c, "layer.flattenImage", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 25"); + + // 26. Duplicate the layer and delete the copy. + run(&c, "layer.duplicate", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 26"); + run(&c, "layer.delete", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 26"); + + // 27. A Curves adjustment darkens the red; deleting it restores the original. + run(&c, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 32], [255, 128]]})).await; + let dark = pixel(&c, 32, 32).await; + assert!(dark[0] < 0.9, "task 27: the red channel deepens {dark:?}"); + run(&c, "layer.delete", json!({})).await; + assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 27: deleting restores the red"); + + // 28. Hue/Saturation at -100 desaturates the red to gray. + run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"saturation": -100})).await; + let gray = pixel(&c, 32, 32).await; + assert!((gray[0] - gray[1]).abs() < 0.05 && (gray[1] - gray[2]).abs() < 0.05, "task 28: saturated red goes gray {gray:?}"); + run(&c, "layer.delete", json!({})).await; + + // 29. Save a selection to a channel, load it back and paint through it. + run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 24, "height": 24})).await; + run(&c, "select.saveSelection", json!({"name": "Box"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "select.loadSelection", json!({"channel": "Box"})).await; + run(&c, "edit.fill", json!({"color": "#0000ff"})).await; + let painted = pixel(&c, 32, 32).await; + let outside = pixel(&c, 4, 4).await; + assert!(painted[2] > 0.99 && painted[0] < 0.01, "task 29: the loaded channel fills blue {painted:?}"); + assert!(outside[0] > 0.99 && outside[2] < 0.01, "task 29: outside keeps red {outside:?}"); + run(&c, "select.deselect", json!({})).await; + + // 30. Save natively with a group, reopen and find the group and its content intact. + run(&c, "layer.new.layer", json!({"name": "Badge"})).await; + run(&c, "select.rect", json!({"x": 40, "y": 40, "width": 16, "height": 16})).await; + run(&c, "edit.fill", json!({"color": "#00ff00"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "select.allLayers", json!({})).await; + run(&c, "layer.groupLayers", json!({"name": "Set"})).await; + tool(&c, "doc_save", json!({"path": "grouped.pcraft"})).await; + tool(&c, "doc_close", json!({})).await; + tool(&c, "doc_open", json!({"path": "grouped.pcraft"})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["kind"], "Group", "task 30: {doc}"); + assert_eq!(top_layer(&doc)["name"], "Set", "task 30: {doc}"); + let green = pixel(&c, 48, 48).await; + assert!(green[1] > 0.99 && green[0] < 0.01, "task 30: the badge inside the group survives {green:?}"); + tool(&c, "doc_close", json!({})).await; + + shutdown(c, server).await; + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/agent_tasks_more.rs b/crates/automation/tests/it/agent_tasks_more.rs similarity index 100% rename from crates/automation/tests/agent_tasks_more.rs rename to crates/automation/tests/it/agent_tasks_more.rs diff --git a/crates/automation/tests/bridge.rs b/crates/automation/tests/it/bridge.rs similarity index 100% rename from crates/automation/tests/bridge.rs rename to crates/automation/tests/it/bridge.rs diff --git a/crates/automation/tests/inactive_save.rs b/crates/automation/tests/it/inactive_save.rs similarity index 100% rename from crates/automation/tests/inactive_save.rs rename to crates/automation/tests/it/inactive_save.rs diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/it/krita.rs similarity index 100% rename from crates/automation/tests/krita.rs rename to crates/automation/tests/it/krita.rs diff --git a/crates/automation/tests/layered_save_dirty.rs b/crates/automation/tests/it/layered_save_dirty.rs similarity index 100% rename from crates/automation/tests/layered_save_dirty.rs rename to crates/automation/tests/it/layered_save_dirty.rs diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs new file mode 100644 index 0000000000..f701bd705b --- /dev/null +++ b/crates/automation/tests/it/main.rs @@ -0,0 +1,16 @@ +//! The crate's integration tests, linked into one test binary. + +mod agent_tasks; +mod agent_tasks_extended; +mod agent_tasks_more; +mod bridge; +mod inactive_save; +mod krita; +mod layered_save_dirty; +mod mcp; +mod mcp_wire; +mod missing_font_warnings; +mod native_save_name; +mod openraster; +mod raw_defaults; +mod smart_filter_authorization; diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/it/mcp.rs similarity index 92% rename from crates/automation/tests/mcp.rs rename to crates/automation/tests/it/mcp.rs index e6df63cd09..fe44d0be6b 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -80,6 +80,7 @@ async fn lists_expected_tools() { "doc_open", "doc_new", "doc_place", + "doc_replace_smart_object", "doc_export_layers", "doc_export_data_sets", "doc_import_data_sets", @@ -706,6 +707,36 @@ async fn doc_place_is_capability_scoped() { cleanup(&base); } +/// #8061: Replace Contents reads from the read root through `doc_replace_smart_object`; the +/// ambient `layer.smartObjects.replaceContents` stays refused. +#[tokio::test(flavor = "multi_thread")] +async fn doc_replace_smart_object_is_capability_scoped() { + let base = tmp("replace-so"); + let root = base.join("workspace"); + let outside = base.join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 144]).unwrap(); + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + std::fs::write(root.join("inside.png"), &bytes).unwrap(); + std::fs::write(outside.join("outside.png"), bytes).unwrap(); + let client = connect(headless_in(&root)).await; + + json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await); + let placed = json_of(&call(&client, "doc_place", json!({"path": "inside.png"})).await); + let layer = placed["layer"].clone(); + let replaced = call(&client, "doc_replace_smart_object", json!({"path": "inside.png", "layer": layer})).await; + assert_ne!(replaced.is_error, Some(true), "{}", text(&replaced)); + let refused = call(&client, "doc_replace_smart_object", json!({"path": outside.join("outside.png").to_string_lossy(), "layer": layer})).await; + assert_eq!(refused.is_error, Some(true)); + assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused)); + let ambient = call(&client, "command_run", json!({"id": "layer.smartObjects.replaceContents", "params": {"path": "inside.png", "layer": layer}})).await; + assert_eq!(ambient.is_error, Some(true)); + assert!(text(&ambient).contains("ambient filesystem paths"), "{}", text(&ambient)); + client.cancel().await.unwrap(); + cleanup(&base); +} + /// #3216: the export half of #2810. The write root scopes both exports, the read root the CSV, /// and the three ambient commands keep their refusal. #[tokio::test(flavor = "multi_thread")] @@ -768,17 +799,55 @@ async fn doc_exports_and_data_set_import_are_capability_scoped() { cleanup(&base); } +/// Skipped-layer details survive the MCP tool result, including the all-skipped case. +#[tokio::test(flavor = "multi_thread")] +async fn doc_export_layers_reports_skipped_layers() { + let base = tmp("skipped-layers"); + let client = connect(headless_in(&base)).await; + json_of(&call(&client, "doc_new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).await); + let background = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"][0]["id"].clone(); + let adjustment = json_of(&call(&client, "command_run", json!({"id": "layer.newAdjustmentLayer.levels"})).await)["layer"].clone(); + json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"name": "Levels/α"}})).await); + let hidden = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Hidden"}})).await)["layer"].clone(); + json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"visible": false}})).await); + let result = json_of(&call(&client, "doc_export_layers", json!({"dir": "layers"})).await); + assert_eq!(result["count"], 1); + assert_eq!(result["files"], json!(["layers/card_0001_Background.png"])); + assert_eq!( + result["skipped"], + json!([ + {"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"}, + {"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"} + ]) + ); + let all = json_of(&call(&client, "doc_export_layers", json!({"dir": "all", "visible_only": false})).await); + assert_eq!(all["count"], 2); + assert_eq!(all["skipped"].as_array().unwrap().len(), 1); + assert_eq!(std::fs::read_dir(base.join("layers")).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(base.join("all")).unwrap().count(), 2); + // Every top-level layer is still accounted for when none can be exported. + json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"layer": background, "visible": false}})).await); + let none = json_of(&call(&client, "doc_export_layers", json!({"dir": "none"})).await); + assert_eq!(none["files"], json!([])); + assert_eq!(none["count"], 0); + assert_eq!(none["skipped"].as_array().unwrap().len(), 3); + assert_eq!(std::fs::read_dir(base.join("none")).unwrap().count(), 0); + client.cancel().await.unwrap(); + cleanup(&base); +} + /// The template workflow stays in memory, including saving nested contents back to the parent. #[tokio::test(flavor = "multi_thread")] async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() { let client = connect(PhotocraftMcp::headless()).await; let run = async |id: &str, params: Value| json_of(&call(&client, "command_run", json!({"id": id, "params": params})).await); json_of(&call(&client, "doc_new", json!({"width": 16, "height": 12, "background": "white"})).await); - run("layer.smartObjects.convertToSmartObject", json!({})).await; let outer = run("layer.smartObjects.convertToSmartObject", json!({})).await["layer"].clone(); // An explicit target works even with a regular pixel layer selected. run("layer.new.layer", json!({"name": "Other layer"})).await; assert_eq!(run("layer.smartObjects.editContents", json!({"layer": outer})).await["document"], 1); + // A smart object cannot be converted again, so nest one inside the contents instead. + run("layer.smartObjects.convertToSmartObject", json!({})).await; assert_eq!(run("layer.smartObjects.editContents", json!({})).await["document"], 2); run("layer.setProps", json!({"name": "Edited photo", "opacity": 0.5})).await; assert_eq!(run("layer.smartObjects.saveContents", json!({})).await["updated"], true); @@ -1042,6 +1111,26 @@ async fn bridge_previews_reject_document_indices() { assert_eq!(schema.get("additionalProperties"), Some(&json!(false))); } +/// Bridge mode runs Replace Contents on the app's active document, so an `index` is refused +/// before anything reaches the app rather than dropped (it would replace another document's +/// contents); without one the command is forwarded as before. +#[tokio::test(flavor = "multi_thread")] +async fn bridge_replace_smart_object_rejects_a_document_index() { + let (addr, app) = fake_app().await; + let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; + let refused = call(&client, "doc_replace_smart_object", json!({"path": "new.png", "index": 1})).await; + assert_eq!(refused.is_error, Some(true), "{}", text(&refused)); + assert!(text(&refused).contains("index"), "{}", text(&refused)); + let forwarded = call(&client, "doc_replace_smart_object", json!({"path": "new.png", "layer": 7})).await; + assert_ne!(forwarded.is_error, Some(true), "{}", text(&forwarded)); + client.cancel().await.unwrap(); + let seen = tokio::time::timeout(std::time::Duration::from_secs(5), app).await.unwrap().unwrap(); + let executed: Vec<&Value> = seen.iter().filter(|request| request["method"] == "engine.execute").collect(); + assert_eq!(executed.len(), 1, "the indexed call must not reach the app: {seen:?}"); + assert_eq!(executed[0]["params"]["command"], "layer.smartObjects.replaceContents"); + assert_eq!(executed[0]["params"]["params"], json!({"path": "new.png", "layer": 7})); +} + #[tokio::test(flavor = "multi_thread")] async fn headless_previews_preserve_document_indices() { let client = connect(PhotocraftMcp::headless()).await; diff --git a/crates/automation/tests/mcp_wire.rs b/crates/automation/tests/it/mcp_wire.rs similarity index 100% rename from crates/automation/tests/mcp_wire.rs rename to crates/automation/tests/it/mcp_wire.rs diff --git a/crates/automation/tests/missing_font_warnings.rs b/crates/automation/tests/it/missing_font_warnings.rs similarity index 100% rename from crates/automation/tests/missing_font_warnings.rs rename to crates/automation/tests/it/missing_font_warnings.rs diff --git a/crates/automation/tests/native_save_name.rs b/crates/automation/tests/it/native_save_name.rs similarity index 100% rename from crates/automation/tests/native_save_name.rs rename to crates/automation/tests/it/native_save_name.rs diff --git a/crates/automation/tests/openraster.rs b/crates/automation/tests/it/openraster.rs similarity index 100% rename from crates/automation/tests/openraster.rs rename to crates/automation/tests/it/openraster.rs diff --git a/crates/automation/tests/it/raw_defaults.rs b/crates/automation/tests/it/raw_defaults.rs new file mode 100644 index 0000000000..0f12e06c5e --- /dev/null +++ b/crates/automation/tests/it/raw_defaults.rs @@ -0,0 +1,20 @@ +//! #7789: the headless (MCP) `doc.open` opens a developed raw at Preferences › Raw Defaults › +//! Resolution, as the app's File › Open does. + +use photocraft_automation::Headless; +use photocraft_raw::testgen::{DngSpec, mosaic, scene}; +use serde_json::json; + +#[test] +fn doc_open_gives_a_raw_the_raw_defaults_resolution() { + let dir = std::env::temp_dir().join(format!("pc-raw-defaults-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (w, h) = (24, 16); + let path = dir.join("IMG_0001.dng"); + std::fs::write(&path, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + let mut headless = Headless::trusted_local(); + headless.handle("engine.execute", json!({"command": "prefs.set", "params": {"path": "rawDefaults.resolution", "value": 240}})).unwrap(); + headless.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(headless.session.active().unwrap().doc.resolution_dpi, 240.0); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/it/smart_filter_authorization.rs similarity index 66% rename from crates/automation/tests/smart_filter_authorization.rs rename to crates/automation/tests/it/smart_filter_authorization.rs index 594832c41d..0a39df85f5 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/it/smart_filter_authorization.rs @@ -1,94 +1,15 @@ //! Stored smart-filter parameters must not grant automation ambient filesystem access. -use std::path::{Path, PathBuf}; -use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, -}; +use std::path::Path; +use std::sync::Arc; -use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step}; -use photocraft_doc::{Document, LayerContent, LayerId}; +#[path = "../support/smart_filter_fixture.rs"] +mod fixture; + +use fixture::{Fixture, assert_denied, cube, path_params}; +use photocraft_automation::{Headless, workspace::authorize_desktop_engine_step}; +use photocraft_doc::LayerContent; use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds}; -use serde_json::{Value, json}; - -struct Fixture(PathBuf); - -impl Fixture { - fn new() -> Self { - static NEXT: AtomicU64 = AtomicU64::new(0); - let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); - std::fs::create_dir(&base).unwrap(); - std::fs::create_dir(base.join("workspace")).unwrap(); - std::fs::create_dir(base.join("outside")).unwrap(); - let f = Self(base); - std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap(); - std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap(); - std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap(); - std::fs::write(f.outside("black.cube"), cube(0)).unwrap(); - std::fs::write(f.outside("white.cube"), cube(1)).unwrap(); - f - } - - fn root(&self) -> PathBuf { - self.0.join("workspace") - } - fn outside(&self, name: &str) -> PathBuf { - self.0.join("outside").join(name) - } - fn headless(&self) -> Headless { - Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap()) - } - fn setup(&self, lookup: bool) -> (Headless, LayerId) { - let mut h = self.headless(); - h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap(); - h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap(); - let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap()); - let (id, params) = if lookup { - ("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"})) - } else { - ("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0})) - }; - h.session.execute(id, params).unwrap(); - (h, layer) - } - fn saved_filter(&self, lookup: bool) -> (Document, LayerId) { - let (h, layer) = self.setup(lookup); - let mut doc = (*h.session.active().unwrap().doc).clone(); - let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") }; - sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" })); - let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); - std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap(); - (doc, layer) - } -} -impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn png(value: impl Fn(u32) -> u8) -> Vec { - let mut pixels = Vec::new(); - for _ in 0..32 { - for x in 0..32 { - pixels.extend([value(x); 3]); - } - } - let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap(); - photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() -} -fn cube(value: u8) -> String { - format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8)) -} -fn path_params(lookup: bool, path: &Path) -> Value { - if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) } -} -fn assert_denied(result: Result) { - match result { - Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"), - Ok(value) => panic!("ambient file access was accepted: {value:?}"), - } -} +use serde_json::json; #[test] fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() { @@ -243,21 +164,6 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ } } -#[test] -fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() { - // The denial path must fail the render before any stage is cached process-wide. - for lookup in [false, true] { - let f = Fixture::new(); - let (doc, layer) = f.saved_filter(lookup); - let mut s = Session::new(); - s.add_document(doc, None); - s.authorize = Some(authorize_desktop_engine_step); - let bytes = smart_cmds::stack_cache_bytes(); - assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); - assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); - } -} - /// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read /// before the edit: a path outside the workspace is refused before that read, and the filter /// keeps the map it had. diff --git a/crates/automation/tests/smart_filter_stack_cache.rs b/crates/automation/tests/smart_filter_stack_cache.rs new file mode 100644 index 0000000000..f7698a4db1 --- /dev/null +++ b/crates/automation/tests/smart_filter_stack_cache.rs @@ -0,0 +1,25 @@ +//! One integration-test process keeps the global smart filter stage cache isolated from the +//! parallel tests in `tests/it`, so a denied render can be shown to have cached nothing. +use photocraft_automation::workspace::authorize_desktop_engine_step; +use photocraft_engine::{Session, smart_cmds}; +use serde_json::json; + +#[path = "support/smart_filter_fixture.rs"] +mod fixture; + +use fixture::{Fixture, assert_denied}; + +#[test] +fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() { + // The denial path must fail the render before any stage is cached process-wide. + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut s = Session::new(); + s.add_document(doc, None); + s.authorize = Some(authorize_desktop_engine_step); + let bytes = smart_cmds::stack_cache_bytes(); + assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); + assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); + } +} diff --git a/crates/automation/tests/support/smart_filter_fixture.rs b/crates/automation/tests/support/smart_filter_fixture.rs new file mode 100644 index 0000000000..26d5cab27b --- /dev/null +++ b/crates/automation/tests/support/smart_filter_fixture.rs @@ -0,0 +1,89 @@ +//! The smart filter authorization tests' fixture: a workspace with a striped image to place and +//! maps and LUTs outside it (`#[path]`-included by `it/smart_filter_authorization.rs` and +//! `smart_filter_stack_cache.rs`). +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use photocraft_automation::{AuthorizedWorkspace, Headless}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use serde_json::{Value, json}; + +pub struct Fixture(PathBuf); + +impl Fixture { + pub fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&base).unwrap(); + std::fs::create_dir(base.join("workspace")).unwrap(); + std::fs::create_dir(base.join("outside")).unwrap(); + let f = Self(base); + std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap(); + std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap(); + std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap(); + std::fs::write(f.outside("black.cube"), cube(0)).unwrap(); + std::fs::write(f.outside("white.cube"), cube(1)).unwrap(); + f + } + + pub fn root(&self) -> PathBuf { + self.0.join("workspace") + } + pub fn outside(&self, name: &str) -> PathBuf { + self.0.join("outside").join(name) + } + pub fn headless(&self) -> Headless { + Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap()) + } + pub fn setup(&self, lookup: bool) -> (Headless, LayerId) { + let mut h = self.headless(); + h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap(); + h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap(); + let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap()); + let (id, params) = if lookup { + ("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"})) + } else { + ("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0})) + }; + h.session.execute(id, params).unwrap(); + (h, layer) + } + pub fn saved_filter(&self, lookup: bool) -> (Document, LayerId) { + let (h, layer) = self.setup(lookup); + let mut doc = (*h.session.active().unwrap().doc).clone(); + let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") }; + sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" })); + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap(); + (doc, layer) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub fn png(value: impl Fn(u32) -> u8) -> Vec { + let mut pixels = Vec::new(); + for _ in 0..32 { + for x in 0..32 { + pixels.extend([value(x); 3]); + } + } + let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap(); + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} +pub fn cube(value: u8) -> String { + format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8)) +} +pub fn path_params(lookup: bool, path: &Path) -> Value { + if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) } +} +pub fn assert_denied(result: Result) { + match result { + Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"), + Ok(value) => panic!("ambient file access was accepted: {value:?}"), + } +} diff --git a/crates/cms/README.md b/crates/cms/README.md index 70b4ab3c9c..b67eb6e8a8 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -27,7 +27,8 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou * `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding. * `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in - sRGB. `content_hash()` compares bytes and suits cache keys only. + sRGB. `same_bytes(&other)` compares the ICC bytes. `content_hash()` is a 64-bit hash of them + that another profile can share: a cache key, confirmed with `same_bytes`. ### Evaluation @@ -75,7 +76,7 @@ sampled gamut boundary. `profiles/photocraft-coated-cmyk.icc` (237 KB, AToB 11 21³ lut16) is that output, dedicated to the public domain (CC0-1.0). Regenerate with ```sh -PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test regen +PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test it -- regen:: ``` It is a plausible coated-offset profile, not a measured characterisation: use your printer's diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index 8b619842d1..963ae3cdb5 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -293,15 +293,22 @@ fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge /// For a flat run at the table start (`start = true`) the last index of the run, or for a flat /// run at the end the first index of it, as a normalised input. +/// +/// A table that is inverted is monotonic, so the run is found by bisection, not by a scan: a long +/// constant table is inverted once per pixel, and scanning it each time made the cost the curve's +/// length times the image's. fn edge_index(t: &[f32], start: bool) -> f32 { let n = t.len(); if start { let v = t[0]; - let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1); + // The first value that differs, given they do so from there on. + let first_other = t.partition_point(|x| (*x - v).abs() <= 1e-7); + let i = if first_other >= n { n - 1 } else { first_other.saturating_sub(1) }; i as f32 / (n - 1) as f32 } else { let v = t[n - 1]; - let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1); + // The start of the trailing run equal to the last value. + let i = t.partition_point(|x| (*x - v).abs() > 1e-7); i as f32 / (n - 1) as f32 } } @@ -362,6 +369,21 @@ mod tests { assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12); } + #[test] + fn inverting_a_long_flat_table_does_not_rescan_it_for_every_pixel() { + // Four million zeros and a one: a scan per call would be 8·10¹¹ steps for these calls. + let mut t = vec![0.0f32; 4_000_000]; + t.push(1.0); + let c = Curve::Table(t); + let started = std::time::Instant::now(); + let mut last = 0.0; + for _ in 0..200_000 { + last = c.eval_inverse(0.0); + } + assert!(last > 0.99, "the flat start ends at the last zero: {last}"); + assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed()); + } + #[test] fn inverse_table_matches_per_call_including_flat_runs() { // Batch inverse must equal calling eval_inverse64 per point. Include a table with flat diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 47ff164fda..6a85f53908 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -90,7 +90,7 @@ impl LutFile { /// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing). pub fn parse(file_name: &str, bytes: &[u8]) -> Result { let lower = file_name.to_ascii_lowercase(); - let text = String::from_utf8_lossy(bytes); + let text = decode_text(bytes); if lower.ends_with(".3dl") { parse_3dl(&text) } else if lower.ends_with(".look") || text.trim_start().starts_with('<') { @@ -100,6 +100,19 @@ pub fn parse(file_name: &str, bytes: &[u8]) -> Result { } } +/// UTF-16 with a byte-order mark (Photoshop loads such `.look` files, #9118), else UTF-8. +fn decode_text(bytes: &[u8]) -> std::borrow::Cow<'_, str> { + let utf16 = |rest: &[u8], from: fn([u8; 2]) -> u16| { + let units: Vec = rest.as_chunks::<2>().0.iter().map(|&c| from(c)).collect(); + String::from_utf16_lossy(&units).into() + }; + match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), + [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), + _ => String::from_utf8_lossy(bytes), + } +} + /// Adobe / Resolve `.cube`. 1D LUTs (`LUT_1D_SIZE`) are expanded to a 33³ table. pub fn parse_cube(text: &str) -> Result { let mut title = String::new(); @@ -107,7 +120,14 @@ pub fn parse_cube(text: &str) -> Result { let (mut dmin, mut dmax) = ([0.0f32; 3], [1.0f32; 3]); let mut rows: Vec = Vec::new(); for line in text.lines() { - let line = line.split('#').next().unwrap_or("").trim(); + // A '#' inside a quoted TITLE is part of the title, not a comment. + let raw = line.trim_start(); + let skip = if raw.starts_with("TITLE") { raw.find('"').and_then(|q| raw[q + 1..].find('"').map(|e| q + e + 2)) } else { None }.unwrap_or(0); + let line = match raw[skip..].find('#') { + Some(i) => &raw[..skip + i], + None => raw, + } + .trim(); if line.is_empty() { continue; } @@ -172,12 +192,15 @@ pub fn parse_cube(text: &str) -> Result { } /// 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. +/// varying fastest; the output bit depth is inferred from the largest value, and is at least 12 +/// bits in a file without a header. pub fn parse_3dl(text: &str) -> Result { let mut rows: Vec<[f64; 3]> = Vec::new(); + 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(); continue; } let v: Vec = line.split_whitespace().filter_map(|s| s.parse().ok()).collect(); @@ -191,7 +214,9 @@ pub fn parse_3dl(text: &str) -> Result { return err(format!("{} rows is not a cube", rows.len())); } let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); - let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) }; + // 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 mut data = vec![0.0f32; n * n * n * 3]; for (i, row) in rows.iter().enumerate() { let (r, g, b) = (i / (n * n), (i / n) % n, i % n); @@ -206,6 +231,15 @@ pub fn parse_3dl(text: &str) -> Result { /// SpeedGrade `.look`: `` and a hex `` string of little-endian float32 RGB triplets /// (red fastest). pub fn parse_look(text: &str) -> Result { + // Comments may hold tag-like text, so drop them before looking for tags (#9109). + let mut clean = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find("").map_or("", |e| &rest[i + 4 + e + 3..]); + } + clean.push_str(rest); + let text = clean.as_str(); let tag = |name: &str| -> Option { let open = format!("<{name}>"); let start = text.find(&open)? + open.len(); @@ -214,7 +248,14 @@ pub fn parse_look(text: &str) -> Result { }; let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no in .look".into()))?; let (entries, _) = checked_data_len(size)?; - let hex: Vec = tag("data").ok_or_else(|| LutError("no in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect(); + let hex: Vec = tag("data").ok_or_else(|| LutError("no in .look".into()))?.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if hex.iter().any(|b| !b.is_ascii_hexdigit()) { + return err("invalid hexadecimal in .look"); + } + // Each float needs eight hex digits; as_chunks would silently discard a partial value. + if !hex.len().is_multiple_of(8) { + return err("incomplete float in .look "); + } let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8; let bytes: Vec = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect(); let floats: Vec = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); @@ -230,6 +271,10 @@ pub fn write_cube(l: &LutFile) -> String { s.push_str(&format!("TITLE \"{}\"\n", l.title.replace('"', "'"))); } s.push_str(&format!("LUT_3D_SIZE {}\n", l.size)); + if l.domain_min != [0.0; 3] || l.domain_max != [1.0; 3] { + s.push_str(&format!("DOMAIN_MIN {} {} {}\n", l.domain_min[0], l.domain_min[1], l.domain_min[2])); + s.push_str(&format!("DOMAIN_MAX {} {} {}\n", l.domain_max[0], l.domain_max[1], l.domain_max[2])); + } for c in l.data.as_chunks::<3>().0 { s.push_str(&format!("{:.6} {:.6} {:.6}\n", c[0], c[1], c[2])); } @@ -301,6 +346,32 @@ pub fn builtin(id: &str) -> Option { mod tests { use super::*; + #[test] + fn cube_title_with_hash_round_trips() { + let l = LutFile::from_fn("Look #3", 2, |c| c); + assert_eq!(parse_cube(&write_cube(&l)).unwrap().title, "Look #3"); + let t = "TITLE \"A #b\" # note\nLUT_3D_SIZE 2 # c\n# comment\n"; + let rows = "0 0 0 # x\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + let p = parse_cube(&format!("{t}{rows}")).unwrap(); + assert_eq!(p.title, "A #b"); + } + + #[test] + fn write_cube_keeps_non_default_domain() { + let mut l = LutFile::identity(2); + l.domain_min = [0.1; 3]; + l.domain_max = [0.9; 3]; + let back = parse_cube(&write_cube(&l)).unwrap(); + assert_eq!(back.domain_min, [0.1; 3]); + assert_eq!(back.domain_max, [0.9; 3]); + l.domain_min = [0.0, 0.0, 0.5]; + l.domain_max = [1.0; 3]; + let back = parse_cube(&write_cube(&l)).unwrap(); + assert_eq!(back.domain_min, [0.0, 0.0, 0.5]); + assert_eq!(back.domain_max, [1.0; 3]); + assert!(!write_cube(&LutFile::identity(2)).contains("DOMAIN")); + } + #[test] fn cube_domain_is_kept_and_validated() { let base = "TITLE \"t\" @@ -396,6 +467,25 @@ DOMAIN_MAX 0.5 1 1 assert!(parse_cube(&text).is_err()); } + #[test] + fn three_dl_without_a_header_reads_low_nodes_as_12_bit() { + let table = |peak: u32| -> String { + let mut text = String::from("0 1023\n"); + for i in 0..8 { + let on = |bit: u32| if i & bit != 0 { peak } else { 0 }; + text.push_str(&format!("{} {} {}\n", on(4), on(2), on(1))); + } + text + }; + for peak in [255, 1023, 4095] { + 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(); + assert_eq!(white, [1.0; 3]); + } + #[test] fn three_dl_blue_fastest_and_bit_depth() { let mut text = String::from("0 1023\n"); @@ -419,6 +509,61 @@ DOMAIN_MAX 0.5 1 1 assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data); } + #[test] + fn look_reads_utf16_with_either_byte_order() { + let id = LutFile::identity(2); + let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let xml = format!("\"2\"\"{hex}\""); + let le: Vec = [0xFF, 0xFE].into_iter().chain(xml.encode_utf16().flat_map(u16::to_le_bytes)).collect(); + let be: Vec = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect(); + assert_eq!(parse("a.look", &le).unwrap().data, id.data); + assert_eq!(parse("a.look", &be).unwrap().data, id.data); + } + + #[test] + fn look_ignores_tags_inside_xml_comments() { + let hex = |l: &LutFile| -> String { l.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect() }; + let inverse = LutFile::from_fn("", 2, |[r, g, b]| [1.0 - r, 1.0 - g, 1.0 - b]); + let xml = format!( + "\"2\"\"{}\"")); + 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); +} + +#[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/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 380b6a7200..cfd1b333e0 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -11,6 +11,7 @@ use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::CommandSpec; +use crate::select_mask_edits; use crate::{EngineError, Result, Session}; fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -221,12 +222,20 @@ impl ObjectFinderCache { /// `area`, say so instead of quietly finding nothing (#5246; scripts often add an empty layer /// above the photo first). fn check_sampled_pixels(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Result<()> { - let Some(surf) = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface) else { return Ok(()) }; - if surf.content_bounds().intersect(&area).is_empty() { - let msg = if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }; - return Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))); + match empty_sampled_area(doc, layer, all_layers, area) { + Some(msg) => Err(bad("select.object", &format!("{msg}; set \"sampleAllLayers\": true or select a layer with pixels"))), + None => Ok(()), } - Ok(()) +} + +/// Why sampling `layer` (unless `all_layers`) can find nothing in `area`: the layer is empty, or has no pixels there. +/// `None` = there is something to sample. The click path reports it as an error, the Object Finder hover as a hint. +pub fn empty_sampled_area(doc: &Document, layer: Option, all_layers: bool, area: Rect) -> Option<&'static str> { + let surf = sampled_target(doc, layer, all_layers).and_then(|id| doc.layer(id)).and_then(Layer::surface)?; + if !surf.content_bounds().intersect(&area).is_empty() { + return None; + } + Some(if surf.content_bounds().is_empty() { "the active layer is empty" } else { "the active layer has no pixels in that area" }) } /// `select.object` with `"point"`: the Object Finder's object under the point (cached). @@ -390,8 +399,9 @@ fn refine_strokes(p: &Value) -> Result> { Ok(strokes) } -/// Select and Mask: refines the selection edge, then outputs to the selection, a layer mask on -/// the active layer, a new layer or a new document (each with or without a mask). +/// Select and Mask: applies the workspace's tool `edits` to the selection (or layer mask), refines +/// its edge, then outputs to the selection, a layer mask on the active layer, a new layer or a new +/// document (each with or without a mask). /// Decontaminating colours needs new pixels, so it upgrades `selection` / `layerMask` output to /// `newLayerWithMask` (as Photoshop does). New-layer outputs hide the source layer; new-document /// outputs leave the source document untouched. @@ -411,13 +421,30 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { if decontaminate && matches!(output.as_str(), "selection" | "layerMask") { output = "newLayerWithMask".into(); } + let edits = select_mask_edits::parse(p)?; + // Before anything is painted: a thin stroke across a huge canvas paints few pixels but many tiles. + select_mask_edits::check_size(&edits, d.doc.bounds())?; + select_mask_edits::check_strokes(&strokes, d.doc.bounds())?; let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| { let mask_surf = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| &m.surface); let target_mask = p.get("target").and_then(Value::as_str) == Some("mask"); - let sel = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }?; + let start = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }; + // The workspace's tool edits change the selection before its edge is refined. + let edited = if edits.is_empty() { None } else { select_mask_edits::apply(doc, active, start.cloned(), &edits) }; + let Some(sel) = (if edits.is_empty() { start } else { edited.as_ref() }) else { return Ok(None) }; let brush = matting::EdgeBrush::new(&strokes, doc.bounds()); - matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), ¶ms, brush.as_ref()) - })?; + // The refined result is dense over the selection's bounds: refuse what would not fit + // rather than allocate it (a thin edit across a huge canvas; the #8783 review). + let content = sel.content_bounds(); + let area = matting::refine_area(content, doc.bounds(), ¶ms, brush.as_ref()); + if u64::from(area.width()) * u64::from(area.height()) > matting::MAX_REFINE_AREA { + return Err(bad( + "select.refineEdge", + &format!("the selection is too large to refine ({} × {} px; at most {} px in all)", area.width(), area.height(), matting::MAX_REFINE_AREA), + )); + } + Ok(matting::refine_mask_brushed(smp, &matting::surface_reader(sel), content, doc.bounds(), ¶ms, brush.as_ref())) + })??; let d = s.active().ok_or(EngineError::NoDocument)?; let active = d.active_layer.filter(|id| d.doc.layer(*id).is_some()); match output.as_str() { @@ -524,7 +551,7 @@ pub fn specs() -> Vec { "select.refineEdge", "Refine Edge", [], - r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask|newDocument|newDocumentWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there)"##, + r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask|newDocument|newDocumentWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[],"edits":[{"tool":"quick","points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add","sampleAllLayers":bool=false} | {"tool":"brush","points":[[x,y],…],"size":px=30,"hardness":0..100=100,"mode":"add|subtract"="add"} | {"tool":"lasso","points":[[x,y],…≥3],"mode":"add|subtract"="add"} | {"tool":"object","rect":[x,y,w,h] | "point":[x,y],"mode":"add|subtract"="add","sampleAllLayers":bool=false} | {"tool":"subject","mode":"replace|add|subtract"="replace","sampleAllLayers":bool=true} | {"tool":"clear"} | {"tool":"invert"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there; edits: the workspace's Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert, applied in order to the selection before its edge is refined)"##, has_selection_or_mask, refine_edge ), @@ -696,6 +723,22 @@ mod tests { assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err()); } + #[test] + fn object_selection_finds_a_large_foreground_at_each_depth_and_undoes() { + for depth in [8, 16, 32] { + let inside = |x: i32, y: i32| (10..190).contains(&x) && (8..152).contains(&y); + let mut s = session_with(200, 160, depth, |x, y| if inside(x as i32, y as i32) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] }); + let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap(); + assert_eq!(r["changed"], true, "depth {depth}"); + assert!(sel_iou(&s, 200, 160, inside) > 0.95, "depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); + let r = s.execute("select.object", json!({"point": [1, 1]})).unwrap(); + assert_eq!(r["changed"], false, "background at depth {depth}"); + assert!(s.active().unwrap().doc.selection.is_none()); + } + } + #[test] fn object_selection_point_honours_the_modes() { let (mut s, _) = disc(8); @@ -1041,4 +1084,174 @@ mod tests { s.execute("select.focusArea", json!({})).unwrap(); assert!(cov(&s, 30, 50) >= 0.5 && cov(&s, 130, 50) < 0.5); } + /// The selection as 8-bit coverage over `w × h`, to compare two selections exactly. + fn sel_bytes(s: &Session, w: i32, h: i32) -> Vec { + (0..h).flat_map(|y| (0..w).map(move |x| (cov(s, x, y) * 255.0).round() as u8)).collect() + } + + /// The #8783 review: a refine whose result would be dense over more than + /// [`matting::MAX_REFINE_AREA`] pixels is refused, not allocated: a thin Brush edit across a + /// huge canvas (its edited selection spans the canvas) and a selection of the whole canvas. + /// The selection stays as it was. + #[test] + fn refine_edge_refuses_a_selection_too_large_to_refine() { + let mut s = Session::new(); + s.add_document( + Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8), + None, + ); + s.execute("select.rect", json!({"x": 10, "y": 60, "width": 10, "height": 10})).unwrap(); + let small = s.active().and_then(|d| d.doc.selection.clone()).expect("selected"); + let diagonal = json!({"edits": [{"tool": "brush", "points": [[0, 0], [99_999, 99_999]], "size": 1}]}); + let t = std::time::Instant::now(); + let e = s.execute("select.refineEdge", diagonal).expect_err("too large to refine"); + assert!(e.to_string().contains("too large to refine"), "{e}"); + assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&small), "the selection is unchanged"); + // The small selection itself still refines. + s.execute("select.refineEdge", json!({"radius": 4})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let all = s.active().and_then(|d| d.doc.selection.clone()).expect("selected"); + assert!(s.execute("select.refineEdge", json!({"radius": 4})).is_err()); + assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&all)); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + } + + /// The #8783 review: Brush and Lasso edits and Refine Edge Brush strokes that would paint + /// too many tiles of a huge canvas are refused before anything is painted, and the selection + /// stays as it was. + #[test] + fn refine_edge_refuses_edits_that_cross_too_many_tiles() { + let mut s = Session::new(); + s.add_document( + Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8), + None, + ); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + let before = s.active().and_then(|d| d.doc.selection.clone()).expect("selected"); + let zigzag: Vec<[f32; 2]> = (0..20).map(|i| [if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0]).collect(); + for p in [ + json!({"edits": [{"tool": "brush", "points": zigzag, "size": 1}]}), + json!({"edits": [{"tool": "lasso", "points": zigzag}]}), + json!({"refineStrokes": [{"points": zigzag, "size": 1}]}), + ] { + let e = s.execute("select.refineEdge", p.clone()).expect_err("refused"); + assert!(e.to_string().contains("too many tiles"), "{p}: {e}"); + assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&before), "{p}: the selection is unchanged"); + } + } + + /// Select and Mask's own tools (#7069, #8148): each edit changes the selection before the + /// edge is refined, at every depth, and the whole run is one history step. + #[test] + fn refine_edge_edits_apply_each_tool_at_every_depth() { + for depth in [8, 16, 32] { + let mut s = two_regions(depth); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + let start = sel_bytes(&s, 140, 100); + let run = |s: &mut Session, edits: Value| { + s.execute("select.refineEdge", json!({"edits": edits})).unwrap(); + let after = sel_bytes(s, 140, 100); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sel_bytes(s, 140, 100), start, "depth {depth}: one undo restores the selection"); + after + }; + let at = |b: &[u8], x: usize, y: usize| b[y * 140 + x]; + // Quick Selection grows across the cool region; subtracting it again leaves the start. + let quick = run(&mut s, json!([{"tool": "quick", "points": [[100, 50], [110, 50]], "size": 8}])); + assert!(at(&quick, 120, 80) >= 128 && at(&quick, 5, 5) >= 128 && at(&quick, 40, 60) < 128, "depth {depth}: quick"); + let back = run( + &mut s, + json!([{"tool": "quick", "points": [[100, 50]], "size": 8}, {"tool": "quick", "points": [[100, 50]], "size": 8, "mode": "subtract"}]), + ); + assert!(at(&back, 120, 80) < 128 && at(&back, 5, 5) >= 128, "depth {depth}: quick subtract"); + // The Brush paints exactly where it goes. + let brush = run(&mut s, json!([{"tool": "brush", "points": [[30, 80], [60, 80]], "size": 10}])); + assert!(at(&brush, 45, 80) == 255 && at(&brush, 45, 90) == 0, "depth {depth}: brush"); + let erased = run(&mut s, json!([{"tool": "brush", "points": [[10, 10]], "size": 6, "mode": "subtract"}])); + assert!(at(&erased, 10, 10) == 0 && at(&erased, 2, 2) == 255, "depth {depth}: brush subtract"); + // Lasso adds and subtracts polygons. + let lasso = run(&mut s, json!([{"tool": "lasso", "points": [[50, 50], [90, 50], [90, 90], [50, 90]]}])); + assert!(at(&lasso, 70, 70) == 255 && at(&lasso, 95, 70) == 0 && at(&lasso, 5, 5) == 255, "depth {depth}: lasso"); + let cut = run(&mut s, json!([{"tool": "lasso", "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "mode": "subtract"}])); + assert!(at(&cut, 5, 5) == 0 && at(&cut, 15, 15) == 255, "depth {depth}: lasso subtract"); + // Clear Selection, then Invert of nothing: everything. + let all = run(&mut s, json!([{"tool": "clear"}, {"tool": "invert"}])); + assert!(all.iter().all(|v| *v == 255), "depth {depth}: clear + invert"); + let inverted = run(&mut s, json!([{"tool": "invert"}])); + assert!(at(&inverted, 5, 5) == 0 && at(&inverted, 100, 50) == 255, "depth {depth}: invert"); + } + } + + /// Object Selection and Select Subject inside the workspace find what the standalone tools find. + #[test] + fn refine_edge_edits_object_and_subject() { + for depth in [8, 16, 32] { + let (mut s, inside) = disc(depth); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("select.refineEdge", json!({"edits": [{"tool": "object", "rect": [40, 20, 120, 120]}, {"tool": "lasso", "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "mode": "subtract"}]})).unwrap(); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.95, "depth {depth}: object rect IoU {v}"); + s.execute("select.refineEdge", json!({"edits": [{"tool": "clear"}, {"tool": "object", "point": [3, 3]}, {"tool": "object", "point": [100, 80]}]})) + .unwrap(); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.9, "depth {depth}: object point IoU {v} (the background point finds nothing)"); + } + let (mut s, inside) = disc(8); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("select.refineEdge", json!({"edits": [{"tool": "subject"}]})).unwrap(); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.9, "subject replaces the selection: IoU {v}"); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("select.refineEdge", json!({"edits": [{"tool": "subject", "mode": "add"}]})).unwrap(); + assert!(cov(&s, 5, 5) >= 0.5 && cov(&s, 100, 80) >= 0.5, "subject added to the selection"); + } + + /// An edit gives what the standalone tool gives, then the edge is refined as usual, and the + /// result reaches every output. + #[test] + fn refine_edge_edits_match_the_tools_and_reach_the_outputs() { + let mut tool = two_regions(8); + tool.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + tool.execute("select.quick", json!({"points": [[100, 50], [110, 50]], "size": 8})).unwrap(); + let mut ws = two_regions(8); + ws.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + ws.execute("select.refineEdge", json!({"edits": [{"tool": "quick", "points": [[100, 50], [110, 50]], "size": 8}]})).unwrap(); + assert_eq!(sel_bytes(&ws, 140, 100), sel_bytes(&tool, 140, 100)); + // 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"); + // 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(); + assert_eq!(r["output"], "layerMask"); + let m = s.active().unwrap().doc.layers[0].mask.as_ref().unwrap(); + assert!(m.surface.sample_channel(10, 20, 0) < 0.01 && m.surface.sample_channel(70, 20, 0) > 0.99); + let mut s = blurred_edge_selected(); + let r = s.execute("select.refineEdge", json!({"output": "newLayerWithMask", "edits": [{"tool": "invert"}]})).unwrap(); + assert_eq!(r["output"], "newLayerWithMask"); + let d = s.active().unwrap(); + let m = d.doc.layers.iter().find_map(|l| l.mask.as_ref()).unwrap(); + assert!(m.surface.sample_channel(10, 20, 0) < 0.01 && m.surface.sample_channel(70, 20, 0) > 0.99); + // Clearing everything deselects. + let mut s = blurred_edge_selected(); + let r = s.execute("select.refineEdge", json!({"edits": [{"tool": "clear"}]})).unwrap(); + assert_eq!(r["selected"], false); + assert!(s.active().unwrap().doc.selection.is_none()); + } + + /// Bad edits are refused before anything changes. + #[test] + fn refine_edge_rejects_bad_edits_and_keeps_the_selection() { + let mut s = blurred_edge_selected(); + let before = sel_bytes(&s, 80, 40); + for bad in + [json!(7), json!([{"tool": "nope"}]), json!([{"tool": "lasso", "points": [[0, 0]]}]), json!([{"tool": "brush", "points": [[0, 0]], "size": -1}])] + { + assert!(s.execute("select.refineEdge", json!({"edits": bad})).is_err(), "{bad}"); + assert_eq!(sel_bytes(&s, 80, 40), before, "{bad}"); + } + } } diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 652ee1d747..78c8b9f2e8 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -132,6 +132,13 @@ impl SnapTargets { if !l.visible || exclude.contains(&l.id) { continue; } + // An artboard is a group, but its edges and centre are the board's own. + if let Some(a) = l.artboard() { + if let Some(r) = to_rect(a.rect) { + t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter); + } + continue; + } if let Some(r) = target_rect_of(doc, l) { t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter); } @@ -389,4 +396,29 @@ mod tests { ); assert_eq!(union([[0.0, 0.0, 1.0, 1.0], [2.0, -1.0, 3.0, 0.5]]), Some([0.0, -1.0, 3.0, 1.0])); } + + /// An artboard (a group) offers the board's own edges and centre, so other layers, boards and + /// tools can snap to it; the board being moved doesn't snap to itself. + #[test] + fn an_artboard_offers_its_own_edges() { + let mut doc = Document::with_background("s", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut board = Layer::group("Artboard 1", vec![]); + if let photocraft_doc::LayerContent::Group(g) = &mut board.content { + g.artboard = + Some(photocraft_doc::Artboard { rect: photocraft_geom::Rect::new(20, 10, 120, 90), background: Default::default(), preset: String::new() }); + } + let id = doc.insert_above(None, board); + let opts = SnapOptions::default(); + let t = SnapTargets::from_document(&doc, &opts, &[]); + let has = |ts: &[Target], kind: SnapKind, pos: f64| ts.iter().any(|t| t.kind == kind && t.pos == pos); + for x in [20.0, 120.0] { + assert!(has(&t.x, SnapKind::LayerEdge, x), "x = {x}"); + } + for y in [10.0, 90.0] { + assert!(has(&t.y, SnapKind::LayerEdge, y), "y = {y}"); + } + assert!(has(&t.x, SnapKind::LayerCenter, 70.0) && has(&t.y, SnapKind::LayerCenter, 50.0)); + let t = SnapTargets::from_document(&doc, &opts, &[id]); + assert!(!has(&t.x, SnapKind::LayerEdge, 120.0) && !has(&t.x, SnapKind::LayerCenter, 70.0)); + } } diff --git a/crates/engine/src/style_asl_cmds.rs b/crates/engine/src/style_asl_cmds.rs new file mode 100644 index 0000000000..840459bf6d --- /dev/null +++ b/crates/engine/src/style_asl_cmds.rs @@ -0,0 +1,342 @@ +//! Styles panel › Import Styles… / Export Styles…: Photoshop layer style libraries (`.asl`). +//! +//! - `style.presets.importAsl` adds every style of an `.asl` file to the Styles library as one +//! group (named after the file), and the patterns embedded in the file to the pattern library so +//! the styles' Pattern Overlays, pattern strokes and bevel textures render. A malformed file +//! changes nothing. +//! - `style.presets.exportAsl` writes styles (all, one group or named ones) as an `.asl`, with the +//! patterns their effects use. +//! +//! The container lives in `photocraft-psd` (`asl`), the mapping onto effects in `photocraft-io` +//! (`asl_map`). + +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::presets::styles::StylePreset; +use crate::presets::{Group, find, unique_name}; +use crate::{EngineError, Result, Session}; +use photocraft_io::asl_map::{self, AslStyle}; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +fn has_styles(s: &Session) -> std::result::Result<(), String> { + if s.presets.styles.iter().any(|g| !g.items.is_empty()) { Ok(()) } else { Err("no styles".into()) } +} + +const IMPORT: &str = "style.presets.importAsl"; +const EXPORT: &str = "style.presets.exportAsl"; + +fn import(s: &mut Session, p: &Value) -> Result { + let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, IMPORT, ".asl file")?; + let group = match p.get("group") { + Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(IMPORT, "`group` must be a non-empty string"))?.to_string(), + None if !stem.trim().is_empty() => stem.trim().to_string(), + None => "Imported Styles".to_string(), + }; + let replace = match p.get("replace") { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad(IMPORT, "`replace` must be a boolean"))?, + }; + // A file named like a built-in group ("Basics.asl") goes to "Basics 2": built-ins are never replaced. + let builtin: Vec = crate::presets::styles::builtin().into_iter().map(|g| g.name).collect(); + let group = if builtin.contains(&group) { (2..).map(|i| format!("{group} {i}")).find(|g| !builtin.contains(g)).unwrap_or(group) } else { group }; + let imp = asl_map::read_asl(&bytes).map_err(|e| bad(IMPORT, e))?; + if imp.styles.is_empty() { + return Err(bad(IMPORT, "the file holds no styles")); + } + // Everything is checked: change the library. + let mut pattern_ids = Vec::new(); + for pat in imp.patterns { + pattern_ids.push(pat.id.clone()); + s.patterns.upsert(pat); + } + let styles = &mut s.presets.styles; + if replace { + styles.retain(|g| g.name != group); + } + let gi = match styles.iter().position(|g| g.name == group) { + Some(i) => i, + None => { + styles.push(Group::new(&group, Vec::new())); + styles.len() - 1 + } + }; + let mut names = Vec::with_capacity(imp.styles.len()); + for st in imp.styles { + // Agents address styles by name: keep names unique across the library. + let name = unique_name(styles, &st.name); + names.push(name.clone()); + let preset = StylePreset { name, effects: st.effects, blend: st.blend, fill_opacity: st.fill_opacity, opacity: st.opacity }; + if let Some(g) = styles.get_mut(gi) { + g.items.push(preset); + } + } + s.presets_changed(); + Ok(json!({"group": group, "imported": names, "count": names.len(), "patterns": pattern_ids, "warnings": imp.warnings})) +} + +fn export(s: &mut Session, p: &Value) -> Result { + let group = match p.get("group") { + None => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad(EXPORT, "`group` must be a string"))?), + }; + let chosen: Vec = match p.get("styles") { + Some(v) => { + let list = v.as_array().ok_or_else(|| bad(EXPORT, "`styles` must be a list of style names"))?; + list.iter() + .map(|n| { + let name = n.as_str().ok_or_else(|| bad(EXPORT, "`styles` must be a list of style names"))?; + let (gi, ii) = find(&s.presets.styles, name, group).ok_or_else(|| bad(EXPORT, format!("no style \"{name}\" (see style.presets.list)")))?; + s.presets.styles.get(gi).and_then(|g| g.items.get(ii)).cloned().ok_or_else(|| bad(EXPORT, format!("no style \"{name}\""))) + }) + .collect::>()? + } + None => match group { + Some(g) => s.presets.styles.iter().find(|x| x.name == g).ok_or_else(|| bad(EXPORT, format!("no style group \"{g}\"")))?.items.clone(), + None => s.presets.styles.iter().flat_map(|g| g.items.iter().cloned()).collect(), + }, + }; + if chosen.is_empty() { + return Err(bad(EXPORT, "no styles to export")); + } + // The patterns the styles use, from the open document or the library. + let all_fx: Vec = chosen.iter().flat_map(|st| st.effects.iter().cloned()).collect(); + let mut patterns: Vec = Vec::new(); + let mut warnings = Vec::new(); + for (name, id) in asl_map::referenced_patterns(&all_fx) { + let key = if id.is_empty() { &name } else { &id }; + match crate::pattern_cmds::resolve(s, key).filter(|_| !key.is_empty()) { + Some(pat) if !patterns.iter().any(|q| q.id == pat.id) => patterns.push(pat), + Some(_) => {} + None => warnings.push(format!("pattern \"{}\" was not found and is not included", if name.is_empty() { &id } else { &name })), + } + } + let styles: Vec = chosen + .iter() + .map(|st| AslStyle { name: st.name.clone(), effects: st.effects.clone(), blend: st.blend, opacity: st.opacity, fill_opacity: st.fill_opacity }) + .collect(); + let bytes = asl_map::write_asl(&styles, &patterns).map_err(|e| bad(EXPORT, e))?; + let mut out = json!({"count": styles.len(), "bytes": bytes.len(), "patterns": patterns.len(), "warnings": warnings}); + match p.get("path") { + Some(v) => { + let path = v.as_str().filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(EXPORT, "`path` must be a non-empty string"))?; + crate::file_cmds::write_file(path, &bytes)?; + out["path"] = json!(path); + } + None => out["data"] = json!(photocraft_paint::tile::b64_encode(&bytes)), + } + Ok(out) +} + +/// `.asl` command specs. +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: IMPORT, + label: "Import Styles…", + menu: &[], + shortcut: None, + params: r##"{"path":".asl file"?,"data":base64 bytes?,"group":string?=file name (a built-in group's name becomes " 2": built-ins are never replaced),"replace":bool=true (replace a group of the same name)} → {group, imported, count, patterns (ids added to the pattern library), warnings}"##, + enabled: always, + run: import, + journal: true, + }, + CommandSpec { + id: EXPORT, + label: "Export Styles…", + menu: &[], + shortcut: None, + params: r##"{"group":name? (only that group),"styles":[names]? (only these),"path":".asl file"? (else the bytes come back as base64 `data`)} → {count, bytes, patterns, warnings, path|data}"##, + enabled: has_styles, + run: export, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_paint::tile::{b64_decode, b64_encode}; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s + } + + #[test] + fn export_then_import_round_trips_a_group() { + let mut s = session(); + let before = s.presets.styles.clone(); + let r = s.execute(EXPORT, json!({"group": "Text Effects"})).unwrap(); + let n = before.iter().find(|g| g.name == "Text Effects").unwrap().items.len(); + assert_eq!(r["count"], n); + let data = r["data"].as_str().unwrap().to_string(); + let r = s.execute(IMPORT, json!({"data": data, "group": "Back"})).unwrap(); + assert_eq!(r["count"], n); + let back = s.presets.styles.iter().find(|g| g.name == "Back").unwrap().items.clone(); + let orig = &before.iter().find(|g| g.name == "Text Effects").unwrap().items; + for (a, b) in back.iter().zip(orig) { + // Names stay unique across the library: "Gold" comes back as "Gold 2". + assert!(a.name.starts_with(&b.name), "{} vs {}", a.name, b.name); + assert_eq!(a.effects.len(), b.effects.len(), "{}", b.name); + assert_eq!(a.fill_opacity, b.fill_opacity, "{}", b.name); + } + // The imported copy applies like the original. + s.execute("style.presets.apply", json!({"preset": back[0].name})).unwrap(); + let fx = &s.active().unwrap().doc.layers.last().unwrap().effects.items; + assert_eq!(fx.len(), orig[0].effects.len()); + // Re-import replaces the group instead of adding another. + let groups = s.presets.styles.len(); + s.execute(IMPORT, json!({"data": data, "group": "Back"})).unwrap(); + assert_eq!(s.presets.styles.len(), groups); + } + + #[test] + fn a_file_named_like_a_built_in_group_keeps_that_group() { + let mut s = session(); + let basics = s.presets.styles.iter().find(|g| g.name == "Basics").unwrap().items.clone(); + let data = s.execute(EXPORT, json!({"group": "Text Effects"})).unwrap()["data"].as_str().unwrap().to_string(); + for _ in 0..2 { + let r = s.execute(IMPORT, json!({"data": data, "group": "Basics"})).unwrap(); + assert_eq!(r["group"], "Basics 2"); + } + assert_eq!(s.presets.styles.iter().find(|g| g.name == "Basics").unwrap().items, basics); + // Re-importing replaces the copy instead of adding another. + assert_eq!(s.presets.styles.iter().filter(|g| g.name.starts_with("Basics")).count(), 2); + } + + #[test] + fn blending_options_survive_and_apply() { + let mut s = session(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": id.0, "opacity": 40, "blend": "multiply", "fillOpacity": 30})).unwrap(); + let l = s.active().unwrap().doc.layer(id).unwrap().clone(); + assert_eq!(l.blend, photocraft_color::BlendMode::Multiply); + s.execute("style.presets.new", json!({"name": "Blended", "group": "Mine", "layer": id.0})).unwrap(); + let data = s.execute(EXPORT, json!({"styles": ["Blended"]})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute(IMPORT, json!({"data": data, "group": "Again"})).unwrap(); + let st = s.presets.styles.iter().find(|g| g.name == "Again").unwrap().items[0].clone(); + assert_eq!(st.blend, Some(l.blend)); + assert!((st.opacity.unwrap() - l.opacity).abs() < 1e-4); + assert!((st.fill_opacity.unwrap() - l.fill_opacity).abs() < 1e-4); + // Applying it to a fresh layer sets the blending options. + s.execute("layer.new.layer", json!({})).unwrap(); + let fresh = s.active().unwrap().active_layer.unwrap(); + s.execute("style.presets.apply", json!({"preset": st.name, "layer": fresh.0})).unwrap(); + let f = s.active().unwrap().doc.layer(fresh).unwrap(); + assert_eq!(f.blend, l.blend); + assert!((f.opacity - l.opacity).abs() < 1e-4); + } + + /// #9318 review: an ASL that replaces a library pattern by id marks its pixels changed, so the + /// preset store writes the replacement (see `PatternLibrary::pixels_rev`). + #[test] + fn an_asl_pattern_replacement_bumps_pixels_rev() { + let mut s = session(); + let mut pat = s.patterns.items[1].clone(); + pat.id = "asl-replaced-pattern".into(); + let asl = |p: &photocraft_doc::Pattern| { + let style = AslStyle { name: "Pat".into(), effects: vec![], blend: None, opacity: None, fill_opacity: None }; + b64_encode(&asl_map::write_asl(&[style], std::slice::from_ref(p)).unwrap()) + }; + s.execute(IMPORT, json!({"data": asl(&pat), "group": "A"})).unwrap(); + let rev = s.patterns.pixels_rev; + pat.surface = s.patterns.items[2].surface.clone(); + s.execute(IMPORT, json!({"data": asl(&pat), "group": "B"})).unwrap(); + assert!(s.patterns.pixels_rev > rev, "the same-id replacement is marked"); + } + + #[test] + fn embedded_patterns_reach_the_library_and_the_document() { + let mut s = session(); + let pat = s.patterns.items[0].clone(); + let fx = crate::layer_style::effect_from_params("patternOverlay", &json!({"pattern": pat.id})).unwrap(); + let (fx, _) = crate::pattern_cmds::resolve_effect(&s, fx).unwrap(); + let mut renamed = pat.clone(); + renamed.id = "asl-test-pattern".into(); + renamed.name = "Asl Test".into(); + let fx = match fx { + photocraft_doc::Effect::PatternOverlay { common, scale, angle, link, phase, .. } => { + photocraft_doc::Effect::PatternOverlay { common, name: renamed.name.clone(), id: renamed.id.clone(), scale, angle, link, phase } + } + other => other, + }; + let style = AslStyle { name: "Pat".into(), effects: vec![fx], blend: None, opacity: None, fill_opacity: None }; + let bytes = asl_map::write_asl(&[style], std::slice::from_ref(&renamed)).unwrap(); + let r = s.execute(IMPORT, json!({"data": b64_encode(&bytes), "group": "Pats"})).unwrap(); + assert_eq!(r["patterns"], json!(["asl-test-pattern"])); + assert!(s.patterns.items.iter().any(|p| p.id == "asl-test-pattern")); + s.execute("style.presets.apply", json!({"preset": "Pat", "group": "Pats"})).unwrap(); + assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.id == "asl-test-pattern")); + // Export embeds it again. + let r = s.execute(EXPORT, json!({"group": "Pats"})).unwrap(); + assert_eq!(r["patterns"], 1); + let back = asl_map::read_asl(&b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(back.patterns.len(), 1); + } + + #[test] + fn files_on_disk() { + let mut s = session(); + let dir = std::env::temp_dir().join(format!("pc-asl-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("My Styles.asl"); + let r = s.execute(EXPORT, json!({"group": "Basics", "path": path.to_string_lossy()})).unwrap(); + assert!(r.get("data").is_none()); + let r = s.execute(IMPORT, json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["group"], "My Styles"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bad_params_fail_gracefully_and_change_nothing() { + let mut s = session(); + let good = s.execute(EXPORT, json!({"group": "Basics"})).unwrap()["data"].as_str().unwrap().to_string(); + let raw = b64_decode(&good).unwrap(); + let styles = s.presets.styles.clone(); + let pats = s.patterns.items.len(); + let empty = b64_encode(&asl_map::write_asl(&[], &[]).unwrap()); + for p in [ + json!({}), + json!({"path": ""}), + json!({"path": "/nonexistent/styles.asl"}), + json!({"data": 42}), + json!({"data": "%%%not base64"}), + json!({"data": ""}), + json!({"data": b64_encode(b"8BPS\0\x01junk")}), + json!({"data": b64_encode(&raw[..raw.len() / 2])}), + json!({"data": empty}), + json!({"data": good, "group": 5}), + json!({"data": good, "group": " "}), + json!({"data": good, "replace": "yes"}), + ] { + assert!(s.execute(IMPORT, p.clone()).is_err(), "{p}"); + } + assert_eq!(s.presets.styles, styles); + assert_eq!(s.patterns.items.len(), pats); + for p in [ + json!({"group": "No Such Group"}), + json!({"group": 7}), + json!({"styles": "Gold"}), + json!({"styles": [3]}), + json!({"styles": ["Nope"]}), + json!({"styles": []}), + json!({"path": 9}), + json!({"path": " "}), + ] { + assert!(s.execute(EXPORT, p.clone()).is_err(), "{p}"); + } + s.presets.styles.clear(); + assert!(s.execute(EXPORT, json!({})).is_err()); + } +} diff --git a/crates/engine/src/swatch_cmds.rs b/crates/engine/src/swatch_cmds.rs index d634de6dee..fc9a4c36b1 100644 --- a/crates/engine/src/swatch_cmds.rs +++ b/crates/engine/src/swatch_cmds.rs @@ -328,6 +328,13 @@ fn use_swatch(s: &mut Session, p: &Value) -> Result { "background" => s.tools.background = c, t => return Err(bad(CMD, format!("unknown target `{t}` (foreground|background)"))), } + // A CMYK swatch keeps its inks (as a colour picked in CMYK does, #4196): the Color Picker + // shows them and a CMYK document is painted with them, not with the sRGB round trip (#7474). + if let SwatchColor::Cmyk(inks) = sw.color + && inks.iter().all(|v| v.is_finite()) + { + s.tools.remember_cmyk(rgb, inks.map(|v| v.clamp(0.0, 1.0))); + } Ok(json!({"swatch": sw.name, "target": target, "hex": hex(rgb)})) } diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index 9c03130c10..54fadda05c 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -57,6 +57,25 @@ fn use_sets_foreground_and_background_through_colour_management() { assert!(s.tools.background.iter().all(|v| (v - 1.0).abs() < 0.01), "{:?}", s.tools.background); } +/// #7474: a CMYK swatch (an .ase Pantone colour) keeps its inks when it becomes the foreground, +/// instead of reading back as what its sRGB stand-in converts to (0/0/100/0 -> 5/1/92/0). +#[test] +fn using_a_cmyk_swatch_remembers_its_inks() { + let mut s = Session::new(); + s.execute("swatches.add", json!({"name": "P 1-8 C", "kind": "spot", "color": {"model": "cmyk", "values": [0, 0, 100, 0]}})).unwrap(); + s.execute("swatches.use", json!({"swatch": "P 1-8 C"})).unwrap(); + let fg = s.tools.foreground; + assert_eq!(s.tools.picked_cmyk([fg[0], fg[1], fg[2]]), Some([0.0, 0.0, 1.0, 0.0])); + s.execute("swatches.add", json!({"name": "Print cyan", "color": {"model": "cmyk", "values": [100, 0, 0, 0]}})).unwrap(); + s.execute("swatches.use", json!({"swatch": "Print cyan", "target": "background"})).unwrap(); + let bg = s.tools.background; + assert_eq!(s.tools.picked_cmyk([bg[0], bg[1], bg[2]]), Some([1.0, 0.0, 0.0, 0.0])); + // An RGB swatch records nothing. + s.execute("swatches.use", json!({"swatch": "Red"})).unwrap(); + let fg = s.tools.foreground; + assert_eq!(s.tools.picked_cmyk([fg[0], fg[1], fg[2]]), None); +} + #[test] fn rename_delete_move_and_groups() { let mut s = Session::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index de40f8662c..954d0c4635 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1573,6 +1573,53 @@ fn document_inspect_and_activate_invalid_index() { assert_eq!(err_act.to_string(), "no document at index 9"); } +/// #7103: Photoshop's Select Document by offset (previous is -1, next +1) walks the open +/// documents in tab order from the active one. Past either end it is refused, not wrapped onto +/// another document, and bad params leave the active document as it was. +#[test] +fn document_activate_by_offset_walks_the_tabs() { + let mut s = Session::new(); + for name in ["a", "b", "c"] { + s.execute("file.new", json!({"width": 2, "height": 2, "name": name})).unwrap(); + } + let active = |s: &Session| s.active().unwrap().doc.name.clone(); + assert_eq!(active(&s), "c"); + assert_eq!(s.execute("document.activate", json!({"offset": -2})).unwrap(), json!({"document": 0})); + assert_eq!(active(&s), "a"); + assert_eq!(s.execute("document.activate", json!({"offset": 1})).unwrap(), json!({"document": 1})); + assert_eq!(active(&s), "b"); + s.execute("document.activate", json!({"offset": 0})).unwrap(); + assert_eq!(active(&s), "b"); + assert_eq!(s.execute("document.activate", json!({"document": 2})).unwrap(), json!({"document": 2})); + // A null key counts as omitted. + s.execute("document.activate", json!({"document": null, "offset": -1})).unwrap(); + assert_eq!(active(&s), "b"); + + let err = s.execute("document.activate", json!({"offset": 2})).unwrap_err().to_string(); + assert_eq!(err, "no document 2 after the active one, which is 2 of 3"); + let err = s.execute("document.activate", json!({"offset": -2})).unwrap_err().to_string(); + assert_eq!(err, "no document 2 before the active one, which is 2 of 3"); + for bad in [ + json!({}), + json!({"offset": null}), + json!({"offset": 1.5}), + json!({"offset": "1"}), + json!({"offset": i64::MAX}), + json!({"offset": i64::MIN}), + json!({"offset": u64::MAX}), + json!({"document": 0, "offset": 1}), + json!({"document": -1}), + ] { + assert!(s.execute("document.activate", bad.clone()).is_err(), "{bad}"); + assert_eq!(active(&s), "b", "{bad}"); + } + // Tabs, not the order the documents were opened in: with "c" dragged to the front, two tabs + // before "b" is "c". + s.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); + s.execute("document.activate", json!({"offset": -2})).unwrap(); + assert_eq!(active(&s), "c"); +} + /// #2166, measured in Photoshop 25.1: a layer mask and the layer pixels keep separate /// foreground/background pairs, swapped when the edit target changes. #[test] @@ -1742,3 +1789,59 @@ fn restore_layer_selection_puts_back_what_still_exists() { assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (None, vec![], None), "no layer selected"); assert!(Session::new().restore_layer_selection(None, &[], None).is_err(), "no document"); } + +/// #4535: every edit snapshots the document into history. The large payloads it carries (the +/// XMP packet, Duotone's PSD data, an unsupported adjustment's bytes) are shared between the +/// snapshot and the document, not copied per state. +#[test] +fn history_states_share_large_document_payloads() { + use photocraft_doc::{Adjustment, Duotone, DuotoneInk, Layer, LayerContent}; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let raw_id = { + let st = s.active_mut().unwrap(); + let doc = std::sync::Arc::make_mut(&mut st.doc); + doc.metadata.xmp = Some("x".repeat(1 << 20).into()); + doc.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3])], psd_raw: Some(vec![7u8; 1 << 16].into()) }); + let layer = Layer::new("kept", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "abcd".into(), raw: vec![9u8; 1 << 16].into() })); + let id = layer.id; + doc.layers.push(layer); + id + }; + s.execute("layer.new.layer", json!({})).unwrap(); + let st = s.active().unwrap(); + let before = st.history.state(st.history.past_len() - 1).unwrap(); + let (now, then) = (&st.doc, &before); + assert!(!std::sync::Arc::ptr_eq(now, then), "the edit made a new document"); + let xmp = |d: &Document| d.metadata.xmp.clone().unwrap(); + assert!(std::sync::Arc::ptr_eq(&xmp(now), &xmp(then)), "the XMP packet is shared"); + let duotone = |d: &Document| d.duotone.as_ref().and_then(|d| d.psd_raw.clone()).unwrap(); + assert!(std::sync::Arc::ptr_eq(&duotone(now), &duotone(then)), "the Duotone data is shared"); + let raw = |d: &Document| match &d.layer(raw_id).unwrap().content { + LayerContent::Adjustment(Adjustment::Unsupported { raw, .. }) => raw.clone(), + other => panic!("{other:?}"), + }; + assert!(std::sync::Arc::ptr_eq(&raw(now), &raw(then)), "the adjustment's bytes are shared"); +} + +#[test] +fn the_document_activation_generation_bumps_on_switch_and_back() { + let mut s = session_with_doc(); + let first = s.active().unwrap().doc.id; + let g1 = s.active_generation(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let second = s.active().unwrap().doc.id; + assert_ne!(second, first); + let g2 = s.active_generation(); + assert!(g2 > g1, "opening a document activates it"); + let i = s.documents().iter().position(|d| d.doc.id == first).expect("the first document"); + assert!(s.set_active(i)); + let g3 = s.active_generation(); + assert!(g3 > g2, "switching back is a new activation"); + // Re-selecting the already-active document is not a switch; neither is an edit. + assert!(s.set_active(i)); + assert_eq!(s.active_generation(), g3); + s.execute("layer.new.layer", json!({})).unwrap(); + assert_eq!(s.active_generation(), g3); + assert_eq!(s.active().unwrap().doc.id, first); +} diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index fce8c1be49..c634441394 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -194,6 +194,8 @@ pub fn lone_target<'a>(doc: &'a Document, layer: Option, p: &Value) -> let m = l.mask.as_ref().ok_or_else(|| EngineError::Other("layer has no mask".into()))?; Ok((!m.linked).then_some(&m.surface)) } + // A filter mask is not transformed on its own: the smart object transforms, as in Photoshop. + Target::FilterMask => Ok(None), Target::Alpha(i) => doc .channels .get(i) @@ -838,13 +840,17 @@ fn transform_doc( // 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))?; - transform_layer_with(if is_group { None } else { sel.as_ref() }, group, l, h, affine, interp, ctl)?; + 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 !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); } + crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), h, interp))?; // The selection outline moves with the pixels. if let Some(sel) = &doc.selection { doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); @@ -859,7 +865,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"="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?,"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)}"##, enabled: has_layer, journal: true, run: transform, diff --git a/crates/engine/src/type_area_cmds.rs b/crates/engine/src/type_area_cmds.rs new file mode 100644 index 0000000000..8bba8c9eca --- /dev/null +++ b/crates/engine/src/type_area_cmds.rs @@ -0,0 +1,96 @@ +//! Area type (#218): type that wraps inside a closed path, like Photoshop's Type tool clicked +//! inside a closed path or shape. `type.detachFromPath` (type_extra_cmds.rs) releases it. + +use photocraft_doc::text::TextShape; +use photocraft_doc::vector::{Knot, Path}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use photocraft_geom::Point; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, layer_param}; +use crate::type_extra_cmds::with_text; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "type.fillPath"; + +// Largest total anchor count of a path area type can fill. Within it every path fits the area +// edge cap, so a big curved path gets "too many anchor points" rather than "encloses no area". +use photocraft_text::area::MAX_AREA_KNOTS; + +fn bad(m: &str) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: m.into() } +} + +fn has_text(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + match d.active_layer.and_then(|id| d.doc.layer(id)).map(|l| &l.content) { + Some(LayerContent::Text(_)) => Ok(()), + _ => Err("the active layer is not a type layer".into()), + } +} + +/// The path area type fills, in document space: the work path (default) or a shape layer's +/// path (`"shape":id`). Every closed subpath counts, with its path operation. +fn area_source(doc: &Document, p: &Value) -> Result { + let path = match p.get("shape") { + None | Some(Value::Null) => doc.work_path.as_ref().ok_or_else(|| bad("the document has no work path; draw a closed path or pass \"shape\":layerId"))?, + Some(v) => { + let id = v.as_u64().ok_or_else(|| bad("\"shape\" must be a layer id"))?; + match doc.layer(LayerId(id)).map(|l| &l.content) { + Some(LayerContent::Shape(sh)) => &sh.path, + Some(_) => return Err(bad("\"shape\" is not a shape layer")), + None => return Err(EngineError::NoLayer(LayerId(id))), + } + } + }; + let knots = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(s.knots.len())); + if knots > MAX_AREA_KNOTS { + return Err(bad("the path has too many anchor points")); + } + if path.inverted { + return Err(bad("an inverted path has no inside to fill")); + } + Ok(path.clone()) +} + +fn fill_path(s: &mut Session, p: &Value) -> Result { + let id = layer_param(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + // `with_text` refuses a locked layer. + let source = area_source(doc, p)?; + let n = source.subpaths.len(); + with_text(s, p, "Area Type", |t, _| { + // The path is in document space; the layer keeps it in text space so the type lands + // inside the path where it is drawn. + let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; + let map = |q: Point| inv.apply(q); + let mut path = source.clone(); + for k in path.subpaths.iter_mut().flat_map(|s| s.knots.iter_mut()) { + *k = Knot { anchor: map(k.anchor), in_ctrl: map(k.in_ctrl), out_ctrl: map(k.out_ctrl), smooth: k.smooth }; + } + let vertical = t.orientation == photocraft_doc::text::Orientation::Vertical; + if photocraft_text::area::AreaShape::new(&path, vertical).is_none() { + return Err(bad("the path encloses no area; draw a closed path")); + } + t.shape = TextShape::Area { path }; + Ok(()) + })?; + Ok(json!({"layer": id.0, "subpaths": n})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: CMD, + label: "Area Type", + menu: &[], + shortcut: None, + params: r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path)} → the text wraps inside the path's closed subpaths, holes included"##, + enabled: has_text, + journal: true, + run: fill_path, + }] +} + +#[cfg(test)] +#[path = "type_area_cmds/tests.rs"] +mod tests; diff --git a/crates/engine/src/type_area_cmds/tests.rs b/crates/engine/src/type_area_cmds/tests.rs new file mode 100644 index 0000000000..ea296015e8 --- /dev/null +++ b/crates/engine/src/type_area_cmds/tests.rs @@ -0,0 +1,232 @@ +use super::*; +use photocraft_doc::text::TextShape; +use photocraft_doc::vector::{Path, PathOp, Subpath}; +use photocraft_doc::{LayerContent, LayerId, TextLayer}; +use serde_json::json; + +fn session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300, "depth": depth, "background": "white"})).unwrap(); + s +} + +fn text_layer(s: &mut Session, text: &str) -> LayerId { + let r = s.execute("type.create", json!({"x": 100, "y": 100, "text": text, "size": 14, "color": "#000000"})).unwrap(); + LayerId(r["layer"].as_u64().unwrap()) +} + +fn text(s: &Session, id: LayerId) -> &TextLayer { + match &s.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Text(t) => t, + c => panic!("not text: {}", c.kind_name()), + } +} + +fn set_work_path(s: &mut Session, p: Path) { + s.edit("path", |doc, _| { + doc.work_path = Some(p); + Ok(()) + }) + .unwrap(); +} + +const WORDS: &str = "Well, that went better than expected. Now where did the cat hide the keys?"; + +#[test] +fn fill_path_wraps_text_inside_the_work_path_in_one_step() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let id = text_layer(&mut s, WORDS); + set_work_path(&mut s, Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (250.0, 50.0), (250.0, 200.0), (50.0, 200.0)])])); + let steps = s.active().unwrap().history.entries().len(); + let r = s.execute("type.fillPath", json!({})).unwrap(); + assert_eq!(r["layer"], json!(id.0)); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one history step"); + assert!(matches!(text(&s, id).shape, TextShape::Area { .. })); + // The ink stays inside the path, in document space. + let ink = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds(); + assert!(ink.x0 >= 49 && ink.x1 <= 251 && ink.y0 >= 49 && ink.y1 <= 201, "depth {depth}: {ink:?}"); + assert!(ink.height() > 30, "several lines"); + assert!(s.undo()); + assert_eq!(text(&s, id).shape, TextShape::Point); + assert!(s.redo()); + assert!(matches!(text(&s, id).shape, TextShape::Area { .. })); + } +} + +#[test] +fn fill_path_uses_a_shape_layer_with_its_hole() { + let mut s = session(8); + let shape = s.execute("shape.create", json!({"kind": "rect", "rect": [20, 20, 300, 200], "fill": "#cccccc"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.create", json!({"kind": "rect", "rect": [140, 80, 60, 80], "addTo": shape, "op": "subtract"})).unwrap(); + let id = text_layer(&mut s, &WORDS.repeat(3)); + s.execute("type.fillPath", json!({"layer": id.0, "shape": shape})).unwrap(); + let TextShape::Area { path } = &text(&s, id).shape else { panic!("area") }; + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[1].op, PathOp::Subtract); +} + +#[test] +fn fill_path_maps_the_path_into_text_space() { + let mut s = session(8); + let id = text_layer(&mut s, WORDS); // transform: translate(100, 100) + set_work_path(&mut s, Path::new(vec![Subpath::polygon(&[(100.0, 100.0), (300.0, 100.0), (300.0, 250.0), (100.0, 250.0)])])); + s.execute("type.fillPath", json!({})).unwrap(); + let TextShape::Area { path } = &text(&s, id).shape else { panic!("area") }; + let a = path.subpaths[0].knots[0].anchor; + assert!((a.x, a.y) == (0.0, 0.0), "{a:?}"); +} + +#[test] +fn fill_path_rejects_bad_paths_without_history() { + let mut s = session(8); + assert!(!s.is_enabled("type.fillPath"), "no type layer"); + let id = text_layer(&mut s, WORDS); + assert!(s.is_enabled("type.fillPath")); + let history = |s: &Session| s.active().unwrap().history.entries().len(); + // No work path. + assert!(s.execute("type.fillPath", json!({})).is_err()); + for bad in [ + Path::new(vec![Subpath::polyline(&[(0.0, 0.0), (90.0, 90.0), (0.0, 90.0)])]), // open only + Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (90.0, 0.0), (180.0, 0.0)])]), // no area + Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (f64::NAN, 0.0), (9.0, 9.0)])]), + { + let mut p = Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (90.0, 0.0), (90.0, 90.0)])]); + p.inverted = true; + p + }, + Path::new(vec![Subpath::polygon(&(0..100_001).map(|i| ((i as f64).cos() * 90.0, (i as f64).sin() * 90.0)).collect::>())]), + ] { + set_work_path(&mut s, bad); + let h = history(&s); + assert!(s.execute("type.fillPath", json!({})).is_err()); + assert_eq!(history(&s), h, "no history step on error"); + assert_eq!(text(&s, id).shape, TextShape::Point); + } + // Bad params, a shape param that is not a shape layer, and a locked layer: each refused + // without a history step or a change to the layer. + let refused = |s: &mut Session, p: serde_json::Value| { + let h = history(s); + assert!(s.execute("type.fillPath", p.clone()).is_err(), "{p}"); + assert_eq!(history(s), h, "no history step on error: {p}"); + assert_eq!(text(s, id).shape, TextShape::Point, "{p}"); + }; + for p in [json!({"shape": "x"}), json!({"shape": u64::MAX}), json!({"layer": u64::MAX}), json!({"shape": id.0})] { + refused(&mut s, p); + } + set_work_path(&mut s, Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (200.0, 0.0), (200.0, 200.0)])])); + s.execute("layer.setProps", json!({"layer": id.0, "locked": true})).unwrap(); + refused(&mut s, json!({})); +} + +#[test] +fn release_turns_area_type_into_box_text_over_the_path() { + let mut s = session(8); + let id = text_layer(&mut s, WORDS); + set_work_path(&mut s, Path::new(vec![Subpath::polygon(&[(100.0, 100.0), (300.0, 100.0), (300.0, 250.0)])])); + s.execute("type.fillPath", json!({})).unwrap(); + assert!(s.is_enabled("type.detachFromPath")); + assert!(!s.is_enabled("type.convertToPointText") && !s.is_enabled("type.convertToParagraphText")); + s.execute("type.detachFromPath", json!({})).unwrap(); + assert_eq!(text(&s, id).shape, TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 150.0 }); + assert!(s.undo()); + assert!(matches!(text(&s, id).shape, TextShape::Area { .. })); +} + +#[test] +fn release_with_a_path_that_is_not_finite_is_an_error() { + let mut s = session(8); + let id = text_layer(&mut s, WORDS); + // A file can carry any coordinates: one NaN or infinite point must not become a NaN text box. + for bad in [f64::NAN, f64::INFINITY] { + let path = Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (100.0, bad), (100.0, 100.0)])]); + s.edit("area", |doc, _| { + if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { + t.shape = TextShape::Area { path }; + } + Ok(()) + }) + .unwrap(); + assert!(s.execute("type.detachFromPath", json!({})).is_err(), "{bad}"); + assert!(matches!(text(&s, id).shape, TextShape::Area { .. }), "unchanged"); + } +} + +#[test] +fn area_type_saves_to_psd_as_paragraph_text_with_a_warning() { + let mut s = session(8); + text_layer(&mut s, WORDS); + set_work_path(&mut s, Path::new(vec![Subpath::polygon(&[(100.0, 100.0), (300.0, 100.0), (300.0, 250.0), (100.0, 250.0)])])); + s.execute("type.fillPath", json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let r = photocraft_io::export(&doc, "x.psd", &photocraft_io::ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("area type")), "{:?}", r.warnings); + let back = photocraft_io::import("x.psd", &r.bytes).unwrap().document; + let t = back.walk().into_iter().find_map(|(_, _, l)| match &l.content { + LayerContent::Text(t) => Some(t.clone()), + _ => None, + }); + let t = t.expect("still a type layer"); + assert_eq!(t.text, WORDS); + assert!(matches!(t.shape, TextShape::Box { width, height, .. } if (width - 200.0).abs() < 0.5 && (height - 150.0).abs() < 0.5), "{:?}", t.shape); +} + +#[test] +fn too_many_anchors_is_its_own_error() { + // A ring of `n` smooth (curved) anchors around (200, 150). + let ring = |n: usize| { + let p = |a: f64, r: f64| photocraft_geom::Point::new(200.0 + r * a.cos(), 150.0 + r * a.sin()); + let step = std::f64::consts::TAU / n as f64; + let knots = (0..n) + .map(|i| { + let a = i as f64 * step; + let (anchor, d) = (p(a, 100.0), 100.0 * step / 3.0); + let (tx, ty) = (-a.sin() * d, a.cos() * d); + let at = |k: f64| photocraft_geom::Point::new(anchor.x + k * tx, anchor.y + k * ty); + photocraft_doc::vector::Knot::smooth(anchor, at(-1.0), at(1.0)) + }) + .collect(); + Path::new(vec![Subpath { closed: true, knots, op: PathOp::Combine }]) + }; + let mut s = session(8); + let id = text_layer(&mut s, WORDS); + // Every anchor up to the limit fits, even with every segment curved. + set_work_path(&mut s, ring(MAX_AREA_KNOTS)); + s.execute("type.fillPath", json!({})).unwrap(); + assert!(matches!(text(&s, id).shape, TextShape::Area { .. })); + // One more is refused for what it is, not as a path that encloses nothing. + let id = text_layer(&mut s, WORDS); + set_work_path(&mut s, ring(MAX_AREA_KNOTS + 1)); + let e = s.execute("type.fillPath", json!({})).unwrap_err().to_string(); + assert!(e.contains("too many anchor points"), "{e}"); + assert_eq!(text(&s, id).shape, TextShape::Point); +} + +/// Image Size with a new resolution keeps area type's pixel size (#7124), so its path grows in +/// text space with the font: the same words wrap onto the same lines, resampled or not. +#[test] +fn a_resolution_change_keeps_area_type_wrapping_where_it_was() { + for (params, k) in [(json!({"resolution": 144, "resample": "none"}), 1), (json!({"width": 800, "height": 600, "resolution": 144}), 2)] { + let mut s = session(8); + let id = text_layer(&mut s, WORDS); + set_work_path(&mut s, Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (250.0, 50.0), (250.0, 200.0), (50.0, 200.0)])])); + s.execute("type.fillPath", json!({})).unwrap(); + let lines = |s: &Session| { + let d = &s.active().unwrap().doc; + let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + eng.layout(text(s, id), d.resolution_dpi).lines.iter().map(|l| l.range.clone()).collect::>() + }; + let size = |s: &Session| { + let mut t = text(s, id).clone(); + crate::type_cmds::refresh(&s.active().unwrap().doc, &mut t); + let b = t.cache.unwrap().content_bounds(); + (b.width(), b.height()) + }; + let (before, (w0, h0)) = (lines(&s), size(&s)); + assert!(before.len() >= 3, "{before:?}"); + s.execute("image.imageSize", params.clone()).unwrap(); + assert_eq!(lines(&s), before, "{params}"); + let (w, h) = size(&s); + assert!(w.abs_diff(w0 * k) <= 3 && h.abs_diff(h0 * k) <= 3, "{params}: {w0}x{h0} -> {w}x{h}"); + } +} diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 44b6a98be4..82ac7a7915 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -use photocraft_color::Color; +use photocraft_color::{Color, PixelFormat}; use photocraft_doc::text::{ AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, @@ -250,6 +250,19 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) { s.font_family = v.to_string(); s.postscript_name = None; + // Photoshop accepts either a family name or a face's PostScript name in the font field. + // Resolve only exact PostScript matches here so an unknown family remains untouched. + let mut text = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + if !text.fonts.has_family(v) { + let resolved = text.fonts.resolve_postscript(v); + if resolved.exact { + s.variations.retain(|f| f.axis != "wght"); + s.font_family = resolved.family; + s.weight = resolved.weight; + s.italic = resolved.italic; + s.postscript_name = Some(v.to_string()); + } + } hit(true); } if let Some(v) = p.get("fontStyle").and_then(Value::as_str) { @@ -527,14 +540,37 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P /// Replaces bytes `a..b` with `new`, keeping styles: replacing a selection keeps the style of its /// first character (as Photoshop does, so retyping a smaller second line stays small); inserting /// at a caret takes the style of the character before it (or after it at the start). +/// The part of `old` that differs from `new`: its byte range in `old` and the text that replaces +/// it, after the longest common prefix and then the longest common suffix that doesn't overlap it +/// (both on character boundaries). +fn changed_span<'a>(old: &str, new: &'a str) -> (usize, usize, &'a str) { + let prefix = old.char_indices().zip(new.chars()).find(|((_, a), b)| a != b).map_or_else(|| old.len().min(new.len()), |((i, _), _)| i); + let (old_rest, new_rest) = (&old[prefix..], &new[prefix..]); + let suffix: usize = old_rest.chars().rev().zip(new_rest.chars().rev()).take_while(|(a, b)| a == b).map(|(a, _)| a.len_utf8()).sum(); + (prefix, old.len() - suffix, &new[prefix..new.len() - suffix]) +} + pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { fn adjust(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> { let mut out: Vec<(usize, S)> = Vec::new(); let mut at = 0; + // Paragraph runs only: the non-empty paragraph that starts exactly at an insertion point. + let starts_here = || { + let mut s = 0; + runs.iter().position(|r| { + let here = s == a && r.0 > 0; + s += r.0; + here + }) + }; let host = if trailing_paragraph && a == b && runs.last().is_some_and(|r| r.0 == 0) && a == runs.iter().map(|r| r.0).sum::() { // Inserted text belongs to a styled final empty paragraph, not to // the preceding paragraph whose break ends at this byte offset. runs.len() - 1 + } else if let Some(next) = (trailing_paragraph && a == b && a > 0).then(starts_here).flatten() { + // Text inserted at the start of a paragraph belongs to that paragraph, not to the one + // whose break ends there (#8489). + next } else { runs.iter() .scan(0, |acc, r| { @@ -590,9 +626,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) { /// Re-renders the layer's pixels and regenerates its PSD `TySh` data. pub fn refresh(doc: &Document, t: &mut TextLayer) { - let dpi = doc.resolution_dpi; + refresh_at(doc.resolution_dpi, doc.pixel_format(), t); +} + +/// [`refresh`] given only the two things it reads from the document: its resolution and its +/// pixel format. A caller holding the layer mutably reads them first instead of cloning the whole +/// document to pass it, once per keystroke and pointer move while typing (#7092). +pub fn refresh_at(dpi: f32, format: PixelFormat, t: &mut TextLayer) { let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); - let (layout, r) = eng.render(t, dpi, doc.pixel_format()); + let (layout, r) = eng.render(t, dpi, format); t.cache = Some(r.surface); t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } @@ -609,11 +651,12 @@ pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> { /// Runs `f` on a type layer as one undo step. `f` may set an explicit layer name (`type.edit`'s /// `name`), which is applied in the same step; otherwise an auto-named layer follows its text. -fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option) -> Result) -> Result { +/// `f` gets the document's resolution (dpi). +fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32, &mut Option) -> Result) -> Result { let id = layer_id(s, p)?; check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; let (r, damage) = s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let auto_named = is_auto_named(l); let LayerContent::Text(t) = &mut l.content else { @@ -621,8 +664,8 @@ fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m }; let before = t.cache.as_ref().map(|c| c.tile_bounds()); let mut rename = None; - let r = f(t, &snapshot, &mut rename)?; - refresh(&snapshot, t); + let r = f(t, dpi, &mut rename)?; + refresh_at(dpi, format, t); // Only this layer's pixels changed: the canvas recomposites their old and new area // instead of the whole document (#124). Unknown old pixels mean a full refresh. let damage = before.zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|(a, b)| a.union(&b)); @@ -689,14 +732,14 @@ pub(crate) fn with_text_targets( check_text_unlocked(doc, *id)?; } let damage = s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let mut damage = Some(photocraft_geom::Rect::EMPTY); for id in &ids { let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) else { return Err(EngineError::NoLayer(*id)) }; let before = t.cache.as_ref().map(|c| c.tile_bounds()); let range = if p.get("layers").is_none() || p.get("layer").is_some() { range_param(&t.text, p) } else { (0, t.text.len()) }; f(t, range)?; - refresh(&snapshot, t); + refresh_at(dpi, format, t); // An unknown old/new cache retains Session.edit's full-refresh fallback. damage = damage.zip(before).zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|((all, a), b)| all.union(&a).union(&b)); } @@ -917,7 +960,7 @@ pub fn specs() -> Vec { } }; let label = if kern_pair.is_some() { "Kerning" } else { "Edit Type" }; - with_text_layer(s, p, label, |t, doc, rename| { + with_text_layer(s, p, label, |t, dpi, rename| { if let Some((at, by)) = kern_pair { // Photoshop's Alt+←/→: the pair before the caret becomes manually kerned, // starting from what it shows now (its metrics/optical or manual value). @@ -929,7 +972,7 @@ pub fn specs() -> Vec { let b = byte_at(&t.text, at); let now = { let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); - eng.pair_kerning(t, doc.resolution_dpi, a) + eng.pair_kerning(t, dpi, a) } .ok_or_else(|| bad("type.edit", "no kerning pair at the caret (line break or line end)"))?; let value = (f64::from(now.round()) + by).clamp(KERN_MIN, KERN_MAX) as f32; @@ -945,8 +988,11 @@ pub fn specs() -> Vec { }); } if let Some(v) = p.get("text").and_then(Value::as_str) { - let n = t.text.len(); - replace_text(t, 0, n, v); + // Replace only what changed, so the unchanged text keeps its character and + // paragraph styles (#8489) instead of the whole text taking the first one. + let v = norm_text(v); + let (a, b, new) = changed_span(&t.text, &v); + replace_text(t, a, b, new); } if let Some(r) = p.get("replace") { let a = byte_at(&t.text, r.get("start").and_then(Value::as_u64).unwrap_or(0) as usize); @@ -1045,16 +1091,16 @@ pub fn specs() -> Vec { run: |s, p| { let id = layer_id(s, p)?; s.edit("Rasterize Type", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is not a type layer", id.0))); }; if t.cache.is_none() { - refresh(&snapshot, t); + refresh_at(dpi, format, t); } - let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(snapshot.pixel_format())); - let surface = if surface.format() == snapshot.pixel_format() { surface } else { surface.convert(snapshot.pixel_format()) }; + let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(format)); + let surface = if surface.format() == format { surface } else { surface.convert(format) }; l.content = LayerContent::Raster(surface); l.psd_blocks.retain(|(k, _)| k != b"TySh"); Ok(()) @@ -1656,6 +1702,66 @@ mod tests { out } + /// #8489: `type.edit {"text"}` changes only what differs, so the rest keeps its styles: an + /// added character takes the style beside it, and a change inside one run stays in it. + #[test] + fn edit_text_keeps_the_styles_of_unchanged_text() { + // (characters, colour) per run. + fn runs(s: &Session, id: u64) -> Vec<(usize, [u8; 4])> { + let t = text_layer(s, id); + let mut at = 0; + t.char_runs() + .into_iter() + .map(|r| { + let n = t.text[at..at + r.len].chars().count(); + at += r.len; + (n, r.style.color.to_rgba8()) + }) + .collect() + } + let (dark, purple) = ([0x1b, 0x1f, 0x4b, 255], [0x5a, 0x3d, 0xf0, 255]); + let mut s = session(); + let id = s.execute("type.create", json!({"x": 20, "y": 50, "text": "Craft Suite in Workshop", "size": 40, "color": "#1b1f4b"})).unwrap()["layer"] + .as_u64() + .unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [15, 23], "color": "#5a3df0"})).unwrap(); + assert_eq!(runs(&s, id), [(15, dark), (8, purple)]); + s.execute("type.edit", json!({"layer": id, "text": "Craft Suite in Workshop!"})).unwrap(); + assert_eq!(runs(&s, id), [(15, dark), (9, purple)], "the added character joins the last run"); + s.execute("type.edit", json!({"layer": id, "text": "Craft Suite in Worksh\u{f6}p!"})).unwrap(); + assert_eq!(runs(&s, id), [(15, dark), (9, purple)], "a change inside a run stays in it"); + s.execute("type.edit", json!({"layer": id, "text": "Kraft Suite in Worksh\u{f6}p!"})).unwrap(); + assert_eq!(runs(&s, id), [(15, dark), (9, purple)]); + // Replacing everything leaves one style, as before. + s.execute("type.edit", json!({"layer": id, "text": "Hello"})).unwrap(); + assert_eq!(text_layer(&s, id).text, "Hello"); + assert_eq!(runs(&s, id).iter().map(|r| r.0).sum::(), 5); + s.execute("type.edit", json!({"layer": id, "text": ""})).unwrap(); + assert_eq!(text_layer(&s, id).text, ""); + + // Text inserted at the start of a paragraph joins that paragraph, which keeps its style. + let id = s + .execute( + "type.create", + json!({"x": 20, "y": 50, "text": "a +b", "size": 20}), + ) + .unwrap()["layer"] + .as_u64() + .unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [2, 3], "align": "right"})).unwrap(); + s.execute( + "type.edit", + json!({"layer": id, "text": "a +Xb"}), + ) + .unwrap(); + let paragraphs: Vec<(usize, photocraft_doc::text::TextAlign)> = + text_layer(&s, id).paragraph_runs().into_iter().map(|r| (r.len, r.style.align)).collect(); + assert_eq!(paragraphs.first().map(|p| p.0), Some(2), "{paragraphs:?}"); + assert_eq!(paragraphs.get(1).copied(), Some((2, photocraft_doc::text::TextAlign::Right)), "{paragraphs:?}"); + } + /// #206: `type.edit {"kerning"}` over a range takes a number (manual, 1/1000 em) or a mode. #[test] fn edit_sets_kerning_over_a_range() { diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index c431a3081d..99538edb70 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -15,7 +15,8 @@ use serde_json::{Value, json}; use crate::commands::{CommandSpec, explicit_layer, layer_param}; use crate::type_cmds::{ - check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets, + check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh_at, replace_text, style_paragraphs, style_range, + with_text_targets, }; use crate::{EngineError, Result, Session}; @@ -44,7 +45,7 @@ fn has_point_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Point => Ok(()), TextShape::Box { .. } => Err("the type layer is already paragraph text".into()), - TextShape::Path { .. } => Err("detach the type layer from its path first".into()), + TextShape::Path { .. } | TextShape::Area { .. } => Err("release the type layer from its path first".into()), } } @@ -53,6 +54,7 @@ fn has_box_text(s: &Session) -> std::result::Result<(), String> { TextShape::Box { .. } => Ok(()), TextShape::Point => Err("the type layer is already point text".into()), TextShape::Path { .. } => Err("the type layer is path text".into()), + TextShape::Area { .. } => Err("the type layer is area type; release it from its path first".into()), } } @@ -68,19 +70,20 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> { // ---------- helpers ---------- -/// Edits a type layer as one history step and re-renders it. -fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result) -> Result { +/// Edits a type layer as one history step and re-renders it. `f` gets the document's resolution +/// (dpi). +pub(crate) fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32) -> Result) -> Result { let id = layer_param(s, p)?; check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; s.edit(label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let auto_named = is_auto_named(l); let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name()))); }; - let r = f(t, &snapshot)?; - refresh(&snapshot, t); + let r = f(t, dpi)?; + refresh_at(dpi, format, t); let name = auto_named.then(|| layer_name(&t.text)); if let Some(n) = name { l.name = n; @@ -90,7 +93,11 @@ fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Tex } fn layout(doc: &Document, t: &TextLayer) -> photocraft_text::TextLayout { - photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, doc.resolution_dpi) + layout_at(doc.resolution_dpi, t) +} + +fn layout_at(dpi: f32, t: &TextLayer) -> photocraft_text::TextLayout { + photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, dpi) } /// `m ∘ translate(tx, ty)`. @@ -144,11 +151,11 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result // ---------- point ⇄ paragraph ---------- fn to_paragraph(s: &mut Session, p: &Value) -> Result { - let r = with_text(s, p, "Convert to Paragraph Text", |t, doc| { - let l = layout(doc, t); - let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * doc.resolution_dpi / 72.0; + let r = with_text(s, p, "Convert to Paragraph Text", |t, dpi| { + let l = layout_at(dpi, t); + let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * dpi / 72.0; let ps = t.paragraph_runs().first().map(|p| p.style.clone()).unwrap_or_default(); - let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * doc.resolution_dpi / 72.0; + let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * dpi / 72.0; if l.vertical { // Line space: u along the columns, v across them (first column centred on v = 0). let [u0, v0, u1, v1] = l.line_bounds().unwrap_or([0.0, -size_px / 2.0, size_px, size_px / 2.0]); @@ -178,7 +185,7 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result { probe.shape = TextShape::Box { x, y: y0, width, height }; // Box text puts the first baseline at the box top plus the ascender height: measure it and // shift the box so that baseline stays at the point-text anchor (y = 0). - let b0 = layout(doc, &probe).lines.first().map_or(0.0, |ln| ln.baseline); + let b0 = layout_at(dpi, &probe).lines.first().map_or(0.0, |ln| ln.baseline); let y = y0 - b0; t.shape = TextShape::Box { x: 0.0, y: 0.0, width, height }; t.transform = then_translate(&t.transform, f64::from(x), f64::from(y)); @@ -188,9 +195,9 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result { } fn to_point(s: &mut Session, p: &Value) -> Result { - let r = with_text(s, p, "Convert to Point Text", |t, doc| { + let r = with_text(s, p, "Convert to Point Text", |t, dpi| { let TextShape::Box { x, y, width, height } = t.shape else { return Err(EngineError::Other("already point text".into())) }; - let l = layout(doc, t); + let l = layout_at(dpi, t); // Vertical type: columns run along the box height. let (x, width) = if l.vertical { (y, height) } else { (x, width) }; let baseline = l.lines.first().map_or(0.0, |ln| ln.baseline); @@ -368,13 +375,24 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => false, Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?, }; + // A document point: the type starts where the path passes nearest it (the Type tool's click). + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => { + let xy = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + Some(xy.filter(|(x, y)| x.is_finite() && y.is_finite()).ok_or_else(|| bad("\"at\" must be [x, y] in document pixels"))?) + } + }; + if at.is_some() && p.get("start").is_some_and(|v| !v.is_null()) { + return Err(bad("pass \"start\" or \"at\", not both")); + } let id = layer_param(s, p)?; let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; if doc.effective_locks(id).all { return Err(EngineError::Other("the type layer is locked".into())); } let sub = path_source(doc, p)?; - with_text(s, p, "Type on a Path", |t, _| { + let start = with_text(s, p, "Type on a Path", |t, _| { // The path lives in document space; the type layer keeps it in its own text space, so // the type lands on the path where it is drawn. let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; @@ -384,15 +402,22 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result { if !path.knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) { return Err(EngineError::Other("the path cannot be mapped into the type layer".into())); } + let start = match at { + None => start, + Some((x, y)) => { + let q = map(Point::new(x, y)); + photocraft_text::warp::arc_length_at(&path, flip, q.x, q.y).ok_or_else(|| bad("the path has no length"))? as f32 + } + }; t.shape = TextShape::Path { path, start, flip }; - Ok(()) + Ok(start) })?; Ok(json!({"layer": id.0, "start": start, "flip": flip})) } fn is_path_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { - TextShape::Path { .. } => Ok(()), + TextShape::Path { .. } | TextShape::Area { .. } => Ok(()), _ => Err("the type layer is not on a path".into()), } } @@ -400,13 +425,21 @@ fn is_path_text(s: &Session) -> std::result::Result<(), String> { fn detach_from_path(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; with_text(s, p, "Release Type from Path", |t, _| { - if !matches!(t.shape, TextShape::Path { .. }) { - return Err(EngineError::Other("the type layer is not on a path".into())); + match &t.shape { + TextShape::Path { .. } => { + // Point text starts where the type started on the path. + let origin = photocraft_text::warp::Warp::on_path(&t.shape).map_or((0.0, 0.0), |w| w.apply(0.0, 0.0)); + t.shape = TextShape::Point; + t.transform = then_translate(&t.transform, origin.0, origin.1); + } + // Area type keeps wrapping, in a text box over the path's bounds. + TextShape::Area { path } => { + let (x0, y0, x1, y1) = photocraft_text::area::path_bounds(path) + .ok_or_else(|| EngineError::Other("the area type's path is empty or has coordinates that aren't finite".into()))?; + t.shape = TextShape::Box { x: x0 as f32, y: y0 as f32, width: ((x1 - x0) as f32).max(1.0), height: ((y1 - y0) as f32).max(1.0) }; + } + _ => return Err(EngineError::Other("the type layer is not on a path".into())), } - // Point text starts where the type started on the path. - let origin = photocraft_text::warp::Warp::on_path(&t.shape).map_or((0.0, 0.0), |w| w.apply(0.0, 0.0)); - t.shape = TextShape::Point; - t.transform = then_translate(&t.transform, origin.0, origin.1); Ok(()) })?; Ok(json!({"layer": id.0})) @@ -499,11 +532,11 @@ fn paste_lorem(s: &mut Session, p: &Value) -> Result { fn update_all(s: &mut Session) -> Result { let mut n = 0; s.edit("Update All Text Layers", |doc, _| { - let snapshot = doc.clone(); - let ids: Vec = snapshot.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); for id in ids { if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(id) { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } @@ -608,7 +641,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map, all: boo } let mut n = 0; s.edit("Replace Missing Fonts", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); for id in &ids { if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(*id) { let mut changed = false; @@ -629,7 +662,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map, all: boo at += r.len; } if changed { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } @@ -722,7 +755,7 @@ pub fn specs() -> Vec { "type.attachToPath", "Type on a Path", &[], - r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##, + r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"at":[x,y]? (instead of "start": begin where the path passes nearest this document point, as a Type tool click does),"flip":bool=false (run along the path the other way, on its other side)}"##, has_text, attach_to_path ), diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index ae913555fd..d1bc3c39f1 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -314,6 +314,34 @@ fn type_attaches_to_a_path_and_releases_it() { assert!(matches!(text(&s, id).shape, TextShape::Path { .. })); } +/// The Type tool's click on a path: `"at"` starts the type where the path passes nearest the +/// click, and the paragraph alignment is kept about that point, as in Photoshop. +#[test] +fn type_attaches_to_a_path_at_a_document_point() { + let mut s = session(8); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(10.0, 60.0), (290.0, 60.0)])])); + Ok(()) + }) + .unwrap(); + for (align, centred) in [("left", false), ("center", true)] { + let r = s.execute("type.create", json!({"x": 150, "y": 60, "text": "Path", "size": 24, "align": align, "color": "#000000"})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert!(s.execute("type.attachToPath", json!({"at": [150, "y"]})).is_err()); + assert!(s.execute("type.attachToPath", json!({"at": [150]})).is_err()); + assert!(s.execute("type.attachToPath", json!({"at": [150, 60], "start": 3})).is_err()); + let r = s.execute("type.attachToPath", json!({"at": [150, 64]})).unwrap(); + assert!((r["start"].as_f64().unwrap() - 140.0).abs() < 0.5, "{r}"); + let b = ink(&s, id); + assert!(b.y1 <= 61, "the type sits on the path: {b:?}"); + if centred { + assert!(b.x0 < 145 && b.x1 > 155, "centred on the click: {b:?}"); + } else { + assert!((148..=156).contains(&b.x0), "starts at the click: {b:?}"); + } + } +} + #[test] fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. diff --git a/crates/engine/src/type_spell_cmds.rs b/crates/engine/src/type_spell_cmds.rs index c544634e5a..d42500c722 100644 --- a/crates/engine/src/type_spell_cmds.rs +++ b/crates/engine/src/type_spell_cmds.rs @@ -16,7 +16,7 @@ use photocraft_text::spell::Dictionary; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{check_text_unlocked, norm_text, refresh, replace_text}; +use crate::type_cmds::{check_text_unlocked, norm_text, refresh_at, replace_text}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -131,10 +131,10 @@ fn change(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, format!("the text at {start}..{end} is {:?}, not {w:?}", &t[a..b]))); } s.edit("Check Spelling", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { replace_text(t, a, b, &to); - refresh(&snapshot, t); + refresh_at(dpi, format, t); } Ok(()) })?; @@ -158,13 +158,13 @@ fn change_all(s: &mut Session, p: &Value) -> Result { let count: usize = plan.iter().map(|(_, h)| h.len()).sum(); if count > 0 { s.edit("Check Spelling", |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); for (id, hits) in &plan { if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) { for (a, b) in hits.iter().rev() { replace_text(t, *a, *b, &to); } - refresh(&snapshot, t); + refresh_at(dpi, format, t); } } Ok(()) @@ -244,10 +244,10 @@ fn insert_text(s: &mut Session, p: &Value) -> Result { let (ba, bb) = (byte_at(t, a), byte_at(t, b)); let label = p.get("label").and_then(Value::as_str).unwrap_or("Insert Glyph").to_string(); s.edit(&label, |doc, _| { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { replace_text(t, ba, bb, &new); - refresh(&snapshot, t); + refresh_at(dpi, format, t); } Ok(()) })?; diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index 93b4261a00..4cbb2092dc 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -17,7 +17,7 @@ use photocraft_doc::{Document, LayerContent, LayerId, TextLayer, TextStyles}; use serde_json::{Map, Value, json}; use crate::commands::CommandSpec; -use crate::type_cmds::{apply_char_props, apply_para_props, refresh}; +use crate::type_cmds::{apply_char_props, apply_para_props, refresh_at}; use crate::{EngineError, Result, Session}; const FAMILY: &str = photocraft_text::fonts::DEFAULT_FAMILY; @@ -292,7 +292,7 @@ fn commit(s: &mut Session, label: &str, new: TextStyles, target: &[Target], op: fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) -> usize { let old = doc.text_styles.clone(); doc.text_styles = new.clone(); - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); let mut changed = 0; for id in ids { @@ -303,7 +303,7 @@ fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) None => restyle(t, &old, new, Some((0, 0)), Op::default()), }; if did { - refresh(&snapshot, t); + refresh_at(dpi, format, t); changed += 1; } } @@ -517,7 +517,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result { } else { doc.text_styles.character.retain(|d| d.id != id); } - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let ids: Vec = doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); for lid in ids { let Some(LayerContent::Text(t)) = doc.layer_mut(lid).map(|l| &mut l.content) else { continue }; @@ -538,7 +538,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result { } } if hit { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } diff --git a/crates/engine/src/updates_cmds.rs b/crates/engine/src/updates_cmds.rs new file mode 100644 index 0000000000..76dbd91b6c --- /dev/null +++ b/crates/engine/src/updates_cmds.rs @@ -0,0 +1,221 @@ +//! Release-check state and commands. The desktop injects a non-blocking transport; the engine +//! owns the result so CLI, MCP and the GUI query the same state without network dependencies. + +use std::sync::mpsc::{Receiver, TryRecvError}; + +use semver::Version; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always}; +use crate::{EngineError, Result, Session}; + +pub const RELEASES: &str = "https://github.com/storytold/photocraft/releases"; +pub type CheckResult = std::result::Result, String>; +/// Starts background work and returns immediately. No document or personal data is sent. +pub type CheckFn = Box std::result::Result, String> + Send>; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Release { + pub version: String, + pub url: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum Status { + #[default] + Idle, + Checking, + UpToDate, + Available { + release: Release, + }, + Failed { + message: String, + }, +} + +#[derive(Default)] +pub struct Updates { + pub check: Option, + pub status: Status, + pending: Option>, +} + +impl Updates { + /// Consume a completed check; a disconnected worker becomes an actionable error. + pub fn poll(&mut self) -> bool { + let Some(rx) = &self.pending else { return false }; + let result = match rx.try_recv() { + Ok(r) => r, + Err(TryRecvError::Empty) => return false, + Err(TryRecvError::Disconnected) => Err("The update check stopped before returning a result".into()), + }; + self.pending = None; + self.status = match result { + Ok(Some(release)) => Status::Available { release }, + Ok(None) => Status::UpToDate, + Err(message) => Status::Failed { message }, + }; + true + } +} + +/// Interpret GitHub's latest-release response. Never trust a remote URL: construct the release +/// page from a validated tag in our own repository. Build metadata doesn't affect precedence. +pub fn parse_release(bytes: &[u8], current: &str) -> CheckResult { + #[derive(Deserialize)] + struct GithubRelease { + tag_name: String, + draft: bool, + prerelease: bool, + } + let release: GithubRelease = serde_json::from_slice(bytes).map_err(|e| format!("Invalid release response: {e}"))?; + if release.draft || release.prerelease { + return Ok(None); + } + let tag = &release.tag_name; + if tag.len() > 128 || !tag.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+')) { + return Err("Invalid release tag".into()); + } + let version = Version::parse(tag.strip_prefix('v').unwrap_or(tag)).map_err(|e| format!("Invalid release version: {e}"))?; + let current = Version::parse(current).map_err(|e| format!("Invalid installed version: {e}"))?; + if !version.pre.is_empty() || !version.cmp_precedence(¤t).is_gt() { + return Ok(None); + } + Ok(Some(Release { version: version.to_string(), url: format!("{RELEASES}/tag/{tag}") })) +} + +fn empty_params(p: &Value) -> Result<()> { + if p.as_object().is_some_and(|p| p.is_empty()) { Ok(()) } else { Err(EngineError::Other("update commands take an empty object".into())) } +} + +fn enabled(s: &Session) -> std::result::Result<(), String> { + s.updates.check.as_ref().map(|_| ()).ok_or_else(|| "Update checks are unavailable in this frontend".into()) +} + +fn check(s: &mut Session, p: &Value) -> Result { + empty_params(p)?; + // Repeated automation requests must not spawn an unbounded number of network workers. + if s.updates.pending.is_none() { + let start = s.updates.check.as_mut().ok_or_else(|| EngineError::Other("Update checks are unavailable in this frontend".into()))?; + match start() { + Ok(rx) => { + s.updates.pending = Some(rx); + s.updates.status = Status::Checking; + } + Err(message) => { + s.updates.status = Status::Failed { message: message.clone() }; + return Err(EngineError::Other(message)); + } + } + } + Ok(json!(s.updates.status)) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "help.checkForUpdates", + label: "Check for Updates…", + menu: &["Help"], + shortcut: None, + params: "{}", + enabled, + run: check, + journal: false, + }, + CommandSpec { + id: "updates.status", + label: "Update Check Status", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: |s, p| { + empty_params(p)?; + s.updates.poll(); + Ok(json!({"supported": s.updates.check.is_some(), "currentVersion": crate::build_info::VERSION, "result": s.updates.status})) + }, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn response(tag: &str) -> Vec { + serde_json::to_vec(&json!({"tag_name": tag, "draft": false, "prerelease": false, "html_url": "https://untrusted.example"})).unwrap() + } + + #[test] + fn semantic_versions_and_trusted_links() { + let release = parse_release(&response("v0.10.0"), "0.9.0").unwrap().unwrap(); + assert_eq!(release.version, "0.10.0"); + assert_eq!(release.url, format!("{RELEASES}/tag/v0.10.0")); + for tag in ["v0.9.0", "v0.8.9", "v0.9.0+build.2", "v1.0.0-rc.1"] { + assert_eq!(parse_release(&response(tag), "0.9.0").unwrap(), None, "{tag}"); + } + assert!(parse_release(&response("0.9.0"), "0.9.0-rc.1").unwrap().is_some()); + } + + #[test] + fn unpublished_and_hostile_responses() { + for flag in ["draft", "prerelease"] { + let mut v: Value = serde_json::from_slice(&response("v1.0.0")).unwrap(); + v[flag] = json!(true); + assert_eq!(parse_release(&serde_json::to_vec(&v).unwrap(), "0.7.0").unwrap(), None); + } + for tag in ["", "latest", "v1.0.0/../evil", "v1.0.0?next=evil", "v1.0.0\n", "版本1.0.0", "999999999999999999999.0.0"] { + assert!(parse_release(&response(tag), "0.7.0").is_err(), "{tag}"); + } + for bytes in [b"not JSON".as_slice(), b"{}", b"null", b"{\"tag_name\":42}"] { + assert!(parse_release(bytes, "0.7.0").is_err()); + } + } + + #[test] + fn commands_are_nonblocking_deduplicated_and_queryable_without_a_document() { + let mut s = Session::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut rx = Some(rx); + s.updates.check = Some(Box::new(move || Ok(rx.take().expect("only one worker")))); + for _ in 0..3 { + assert_eq!(s.execute("help.checkForUpdates", json!({})).unwrap()["status"], "checking"); + } + tx.send(Ok(Some(Release { version: "1.0.0".into(), url: format!("{RELEASES}/tag/v1.0.0") }))).unwrap(); + let status = s.execute("updates.status", json!({})).unwrap(); + assert_eq!(status["result"]["status"], "available"); + assert_eq!(status["result"]["release"]["version"], "1.0.0"); + assert!(s.journal.is_empty()); + } + + #[test] + fn graceful_failures_and_preference_compatibility() { + let mut s = Session::new(); + assert!(s.execute("help.checkForUpdates", json!({})).is_err()); + for p in [Value::Null, json!([]), json!({"url": "evil"}), json!("bad")] { + assert!(s.execute("updates.status", p.clone()).is_err()); + assert!(check(&mut s, &p).is_err()); + } + s.updates.check = Some(Box::new(|| Err("Could not start worker".into()))); + assert!(s.execute("help.checkForUpdates", json!({})).is_err()); + assert!(matches!(s.updates.status, Status::Failed { .. })); + s.updates.check = Some(Box::new(|| { + let (_tx, rx) = std::sync::mpsc::channel(); + Ok(rx) + })); + s.execute("help.checkForUpdates", json!({})).unwrap(); + assert_eq!(s.execute("updates.status", json!({})).unwrap()["result"]["status"], "failed"); + s.load_prefs_json("{}").unwrap(); + assert!(s.prefs().general.automatically_check_updates); + s.execute("prefs.set", json!({"path": "general.automaticallyCheckUpdates", "value": false})).unwrap(); + let saved = s.prefs_to_json(); + let mut restored = Session::new(); + restored.load_prefs_json(&saved).unwrap(); + assert!(!restored.prefs().general.automatically_check_updates); + } +} diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index 0a49d780d5..9c89c4756e 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -165,7 +165,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<( } } (VarKind::TextReplacement, VarValue::Text(text)) => { - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); if let Some(l) = doc.layer_mut(def.layer) && let LayerContent::Text(t) = &mut l.content { @@ -180,7 +180,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<( t.sync_summary(); // The compositor draws a type layer from its cache: clearing it left the // layer blank until some other edit re-rendered it (#990). - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); } } (VarKind::PixelReplacement { method, align, clip }, VarValue::Pixels(path)) => { @@ -243,10 +243,9 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM Ok(()) } -fn apply_data_set(s: &mut Session, p: &Value) -> Result { - let (set, idx) = resolve_set(s, p, "image.applyDataSet")?; - let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone(); - // Pixel Replacement reads image files, so an untrusted session's gate judges each path. +/// Pixel Replacement reads image files, so an untrusted session's gate judges each path a data +/// set would read: whether it is applied or exported, the path is document data. +fn authorize_pixel_paths(s: &Session, set: &DataSet) -> Result<()> { if let Some(auth) = s.authorize { for dv in &set.values { if let VarValue::Pixels(path) = &dv.value { @@ -254,6 +253,13 @@ fn apply_data_set(s: &mut Session, p: &Value) -> Result { } } } + Ok(()) +} + +fn apply_data_set(s: &mut Session, p: &Value) -> Result { + let (set, idx) = resolve_set(s, p, "image.applyDataSet")?; + let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone(); + authorize_pixel_paths(s, &set)?; s.edit(&format!("Apply Data Set \"{}\"", set.name), |doc, _| apply_to_doc(doc, &vars, &set))?; set_vars(s, |v| v.active = Some(idx))?; Ok(json!({"applied": set.name, "index": idx})) @@ -409,9 +415,22 @@ pub fn export_data_sets_with(s: &Session, p: &Value, mut write: impl FnMut(&str, if sets.is_empty() { return Err(bad("file.export.dataSetsAsFiles", "no data sets to export")); } + // Judge every image path before anything is written, as Apply Data Set does. + for set in &sets { + authorize_pixel_paths(s, set)?; + } // Filename template: `{name}` (sanitised data-set name), `{index}` (1-based). Default `{name}`. let template = p.get("naming").and_then(Value::as_str).unwrap_or("{name}"); - let doc_stem = original.name.rsplit_once('.').map_or(original.name.as_str(), |(a, _)| a).to_string(); + // A document name is not a path: a separator in it, or a stem of `.` / `..` before a `/` in + // the template, must not leave `dir`. + let doc_stem: String = original + .name + .rsplit_once('.') + .map_or(original.name.as_str(), |(a, _)| a) + .chars() + .map(|c| if c.is_control() || "/\\<>:\"|?*".contains(c) { '_' } else { c }) + .collect(); + let doc_stem = if matches!(doc_stem.as_str(), "" | "." | "..") { "_".to_string() } else { doc_stem }; // Plan the whole batch before writing: sanitised names or a constant template may collide. // Match the case-insensitive comparison used by the other batch exporters. let mut claimed = std::collections::HashMap::new(); @@ -419,7 +438,15 @@ pub fn export_data_sets_with(s: &Session, p: &Value, mut write: impl FnMut(&str, for (n, set) in sets.iter().enumerate() { let safe: String = set.name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); let stem = template.replace("{name}", &safe).replace("{index}", &format!("{:03}", n + 1)).replace("{document}", &doc_stem); - let name = format!("{stem}.{format}"); + // Each folder the template makes is checked too: `{document}/{name}` of `nul.psd` would + // otherwise write into the `nul` device. + let name: String = format!("{stem}.{format}") + .split_inclusive(['/', '\\']) + .map(|part| { + let sep = ["/", "\\"].into_iter().find(|s| part.ends_with(s)).unwrap_or(""); + crate::file_cmds::portable_device_name(part.strip_suffix(sep).unwrap_or(part).to_string()) + sep + }) + .collect(); if let Some(first) = claimed.insert(name.to_lowercase(), &set.name) { return Err(bad( "file.export.dataSetsAsFiles", @@ -432,7 +459,8 @@ pub fn export_data_sets_with(s: &Session, p: &Value, mut write: impl FnMut(&str, for (set, name) in sets.iter().zip(&names) { let mut doc = (*original).clone(); apply_to_doc(&mut doc, &vars, set)?; - let opts = photocraft_io::ExportOptions::default(); + // An untrusted session's PSD export embeds no file a linked smart object names. + let opts = photocraft_io::ExportOptions { read_linked_files: s.authorize.is_none(), ..Default::default() }; let bytes = photocraft_io::export(&doc, format, &opts).map(|r| r.bytes).map_err(|e| EngineError::Other(format!("export `{}`: {e}", set.name)))?; write(name, &bytes)?; written.push(name.clone()); diff --git a/crates/engine/src/variables_cmds/tests.rs b/crates/engine/src/variables_cmds/tests.rs index 8a0c591e71..ec5b728e71 100644 --- a/crates/engine/src/variables_cmds/tests.rs +++ b/crates/engine/src/variables_cmds/tests.rs @@ -421,6 +421,39 @@ fn export_data_sets_rejects_collisions_before_writing() { } } +#[test] +fn export_data_sets_keeps_document_derived_names_inside_the_folder() { + // A document name is not a path: `{document}` must not climb out of `dir` or into a subfolder, + // and a data set named after a Windows device gets the `_` every other exporter gives it. + let root = tempfile::tempdir().unwrap(); + let root = root.path().to_string_lossy().into_owned(); + let dir = format!("{root}/out"); + let mut s = visibility_export_session(&["nul", "b"]); + s.execute("image.duplicate", json!({"name": "../esc/aped.psd"})).unwrap(); + let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "naming": "{document}_{index}"})).unwrap(); + assert_eq!(r["files"], json!([format!("{dir}/.._esc_aped_001.png"), format!("{dir}/.._esc_aped_002.png")])); + let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["nul"]})).unwrap(); + assert_eq!(r["files"], json!([format!("{dir}/nul_.png")])); + // `...` has the stem `..`: before a folder in the caller's template it would climb out too. + s.execute("image.duplicate", json!({"name": "..."})).unwrap(); + let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}/{name}"})).unwrap(); + assert_eq!(r["files"], json!([format!("{dir}/_/b.png")])); + // `.` has an empty stem: `{document}` alone would be a nameless `.png`. + s.execute("image.duplicate", json!({"name": "."})).unwrap(); + let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}"})).unwrap(); + assert_eq!(r["files"], json!([format!("{dir}/_.png")])); + // A folder the template makes from a device-named document gets the `_` as well. + s.execute("image.duplicate", json!({"name": "nul.psd"})).unwrap(); + let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "dataSets": ["b"], "naming": "{document}/{name}"})).unwrap(); + assert_eq!(r["files"], json!([format!("{dir}/nul_/b.png")])); + let mut written: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + written.sort(); + assert_eq!(written, [".._esc_aped_001.png", ".._esc_aped_002.png", "_", "_.png", "nul_", "nul_.png"]); + assert!(std::path::Path::new(&format!("{dir}/nul_/b.png")).is_file()); + assert!(!std::path::Path::new(&format!("{root}/esc")).exists()); + assert!(!std::path::Path::new(&format!("{root}/b.png")).exists()); +} + #[test] fn export_data_sets_unique_paths_preserve_variations_and_document() { for (label, names, naming, selected, expected) in [ diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 7a871abad9..989f312db8 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -163,6 +163,11 @@ pub(crate) fn color(v: &Value) -> Option { _ => None, } } + // A stored `Color` (mode + components), so a CMYK or Lab gradient stop survives a + // read-modify-write through the shape swatches. + Value::Object(_) => serde_json::from_value::(v.clone()) + .ok() + .filter(|c| c.c.iter().all(|x| x.is_finite()) && c.alpha.is_finite() && (0.0..=1.0).contains(&c.alpha)), _ => None, } } @@ -247,15 +252,21 @@ pub fn path_json(p: &Path) -> Value { fn fill_json(f: &Fill) -> Value { match f { Fill::Solid(c) => json!(hex(c)), - Fill::Gradient { stops, angle, scale, style, reverse, .. } => json!({ "gradient": { + Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": { "stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::>(), - "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse } }), + "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse, + "opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align } }), Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }), } } -/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"],…],"angle","scale","style","reverse"}}` / -/// `{"pattern":name,"scale"}`. `null` → `Ok(None)` (no fill). +/// The most colour stops, opacity stops or midpoints a gradient fill takes, as the gradient stop +/// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them. +const MAX_GRADIENT_STOPS: usize = 1024; + +/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse", +/// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`. +/// `null` → `Ok(None)` (no fill). fn parse_fill(v: &Value) -> std::result::Result, String> { if v.is_null() { return Ok(None); @@ -264,9 +275,14 @@ fn parse_fill(v: &Value) -> std::result::Result, String> { return Ok(Some(Fill::Solid(c))); } if let Some(g) = v.get("gradient") { - let stops: Vec<(f32, Color)> = g - .get("stops") - .and_then(Value::as_array) + // Stop lists are capped as `gradient.fill.stop` caps them: more is an error, not a cut. + let list = |key: &str, what: &str| match g.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(a)) if a.len() > MAX_GRADIENT_STOPS => Err(format!("too many {what} (max {MAX_GRADIENT_STOPS})")), + Some(Value::Array(a)) => Ok(Some(a)), + Some(_) => Err(format!("gradient `{key}` must be an array")), + }; + let stops: Vec<(f32, Color)> = list("stops", "colour stops")? .map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect()) .unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") { @@ -276,13 +292,35 @@ fn parse_fill(v: &Value) -> std::result::Result, String> { "diamond" => GradientStyle::Diamond, _ => GradientStyle::Linear, }; - return Ok(Some(Fill::gradient( + let mut fill = Fill::gradient( stops, f64p(g, "angle").unwrap_or(90.0) as f32, f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0, style, g.get("reverse").and_then(Value::as_bool).unwrap_or(false), - ))); + ); + // The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing. + if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill { + let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32); + if let Some(a) = list("opacityStops", "opacity stops")? { + *opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect(); + opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + } + if let Some(a) = list("midpoints", "midpoints")? { + *midpoints = a.iter().filter_map(unit).collect(); + } + if let Some(o) = g.get("offset") { + let at = |i: usize| o.get(i).and_then(Value::as_f64).map(|n| n.clamp(-1000.0, 1000.0) as f32); + *offset = (at(0).ok_or("gradient offset must be [x, y]")?, at(1).ok_or("gradient offset must be [x, y]")?); + } + if let Some(d) = g.get("dither").and_then(Value::as_bool) { + *dither = d; + } + if let Some(a) = g.get("align").and_then(Value::as_bool) { + *align = a; + } + } + return Ok(Some(fill)); } if let Some(name) = v.get("pattern").and_then(Value::as_str) { return Ok(Some(Fill::Pattern { @@ -412,13 +450,19 @@ fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>) } "ellipse" => LiveShape::Ellipse { rect: rect()? }, "polygon" | "star" => { - let (ps, pr) = match prev { - Some(LiveShape::Polygon { sides, star_ratio, .. }) => (*sides, *star_ratio), - _ => (5, if kind == "star" { 0.5 } else { 1.0 }), + let (ps, pr, pc) = match prev { + Some(LiveShape::Polygon { sides, star_ratio, corner_radius, .. }) => (*sides, *star_ratio, *corner_radius), + _ => (5, if kind == "star" { 0.5 } else { 1.0 }, 0.0), }; + let corner_radius = f64p(p, "cornerRadius").map_or(pc, |v| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { pc }); let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32); let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0)); - LiveShape::Polygon { rect: rect()?, sides, star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio } } + LiveShape::Polygon { + rect: rect()?, + sides, + star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio }, + corner_radius, + } } "line" => { let (pf, pto, pw) = match prev { @@ -481,10 +525,7 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { vm.path = vm.path.transform(&a); } match &mut l.content { - LayerContent::Shape(sh) => { - transform_shape(sh, &a); - refresh_shape(doc, sh); - } + LayerContent::Shape(sh) => shift_shape(doc, sh, dx, dy), LayerContent::Group(g) => { for c in &mut g.children { translate_vectors(doc, c, dx, dy); @@ -494,6 +535,31 @@ pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { } } +/// Moves a shape by `(dx, dy)`. A whole-pixel move shifts the rendered pixels as they are, so a +/// shape from a PSD keeps Photoshop's anti-aliasing (as moved type does). The render is cut at +/// the canvas, so a shape reaching past it renders again, as does a fractional move. +pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) { + transform_shape(sh, &Affine::translate(dx, dy)); + let whole = |v: f64| (v.fract() == 0.0 && v.abs() <= f64::from(i32::MAX)).then_some(v as i32); + let canvas = doc.bounds(); + let inside = sh.cache.as_ref().is_some_and(|c| { + let b = photocraft_compose::bounds::content_bounds(c); + // An empty cache can be an off-canvas shape moving back into view. + !b.is_empty() && b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1 + }); + let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil())); + let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { + (x0 - stroke_pad) > canvas.x0 as f64 + && (y0 - stroke_pad) > canvas.y0 as f64 + && (x1 + stroke_pad) < canvas.x1 as f64 + && (y1 + stroke_pad) < canvas.y1 as f64 + }); + match (&mut sh.cache, whole(dx), whole(dy)) { + (Some(c), Some(dx), Some(dy)) if inside && stays_inside => *c = crate::layer_multi_cmds::shift_surface(c, dx, dy), + _ => refresh_shape(doc, sh), + } +} + /// Applies an affine transform to a shape's path, keeping the live shape when the transform /// is a translation plus axis-aligned scale (otherwise the shape stops being parametric). pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { @@ -510,7 +576,9 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) } } LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) }, - LiveShape::Polygon { rect, sides, star_ratio } => LiveShape::Polygon { rect: r(rect), sides, star_ratio }, + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } + } LiveShape::Line { from, to, weight } => { // Live-shape edits regenerate the line's filled bar from its parameters. // Keep the weight in sync with a positive scale of the path, or an @@ -704,7 +772,7 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result { sh.live = None; sh.psd_raw = None; } - let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "from", "to", "weight"]; + let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "cornerRadius", "from", "to", "weight"]; if geom_keys.iter().any(|k| p.get(*k).is_some()) { let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string())); let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?; @@ -1425,7 +1493,8 @@ fn vector_mask_add(s: &mut Session, p: &Value, from_path: bool) -> Result if locked { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } - l.vector_mask = Some(VectorMask::new(path)); + crate::extra_cmds::background_to_layer(doc, id); + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.vector_mask = Some(VectorMask::new(path)); Ok(()) })?; vector_mask_info(s, id) @@ -1725,14 +1794,14 @@ pub(crate) fn leak(s: String) -> &'static str { pub fn specs() -> Vec { let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##; - let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool}}|{"pattern":name}|null (no fill)"##; + let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]|stored Color]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[t,0..1]]?,"midpoints":[0..1]?,"offset":[x,y]?,"dither":bool?,"align":bool?}}|{"pattern":name}|null (no fill)"##; vec![ spec!( "shape.create", "New Shape Layer", ["Layer", "New"], leak(format!( - r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## + r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## )), has_doc, shape_create @@ -1742,7 +1811,7 @@ pub fn specs() -> Vec { "Edit Shape", [], leak(format!( - r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## + r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":px?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## )), has_layer, shape_edit diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 0789e900ad..0b5b89aae9 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -27,15 +27,83 @@ fn stroke_validation_rejects_hostile_values_without_editing_history() { json!({"dashOffset": 1e100}), json!({"width": "4"}), json!({"width": 0.00001, "dashes": [0.01, 0.01]}), + json!({"color": {"gradient": {"offset": [0.5]}}}), + json!({"color": {"gradient": {"offset": "centre"}}}), + // Stop lists past the gradient stop edits' 1024 cap are refused, not cut. + json!({"color": {"gradient": {"stops": vec![json!([0.5, "#ff0000"]); 1025]}}}), + json!({"color": {"gradient": {"opacityStops": vec![json!([0.5, 1.0]); 1025]}}}), + json!({"color": {"gradient": {"midpoints": vec![0.5; 1025]}}}), + // A stop list that is not an array is an error, not the default black-to-white. + json!({"color": {"gradient": {"stops": "red"}}}), + json!({"color": {"gradient": {"opacityStops": {}}}}), + json!({"color": {"gradient": {"midpoints": 1}}}), ]; for stroke in invalid { assert!(s.execute("shape.edit", json!({"layer": id, "stroke": stroke})).is_err(), "{stroke}"); assert_eq!(s.active().unwrap().doc, before); assert_eq!(s.active().unwrap().history.past_len(), history); assert!(s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": stroke})).is_err(), "{stroke}"); + assert_eq!(s.active().unwrap().doc, before, "{stroke}: a rejected create adds no layer"); + assert_eq!(s.active().unwrap().history.past_len(), history, "{stroke}: nor a history state"); } assert!(s.execute("shape.edit", json!({"layer": id, "rect": [1e100, 0, 20, 20]})).is_err()); assert_eq!(s.active().unwrap().doc, before); + // The cap itself is accepted. + let at_cap = json!({"color": {"gradient": {"opacityStops": vec![json!([0.5, 1.0]); 1024], "midpoints": vec![0.5; 1024]}}}); + s.execute("shape.edit", json!({"layer": id, "stroke": at_cap})).unwrap(); +} + +/// A gradient stop given as a stored `Color` keeps its colour model: a CMYK stop stays CMYK. +#[test] +fn gradient_stops_take_a_stored_color_and_keep_its_model() { + let mut s = session(100, 80, 8); + let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 6}})).unwrap()["layer"].as_u64().unwrap(); + let cmyk = Color { mode: photocraft_doc::ColorMode::Cmyk, c: [0.1, 0.2, 0.3, 0.9], alpha: 0.75 }; + let stops = json!([[0.0, cmyk], [1.0, "#ffffff"]]); + s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": stops}}}})).unwrap(); + let doc = &s.active().unwrap().doc; + let LayerContent::Shape(sh) = &doc.layer(LayerId(id)).unwrap().content else { panic!("a shape layer") }; + let Fill::Gradient { stops, .. } = &sh.stroke.as_ref().unwrap().paint else { panic!("a gradient stroke") }; + assert_eq!(stops[0], (0.0, cmyk)); + assert_eq!(stops[1], (1.0, Color::WHITE), "hex stops still parse as before"); + // A stored colour that isn't finite or has alpha past 1 is not a colour: the stop is dropped, + // as an unparseable hex stop is. + let bad = json!({"mode": "Cmyk", "c": [0.1, 0.2, 0.3, 0.9], "alpha": 2.0}); + s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": [[0.0, bad], [1.0, "#000000"]]}}}})).unwrap(); + let doc = &s.active().unwrap().doc; + let LayerContent::Shape(sh) = &doc.layer(LayerId(id)).unwrap().content else { panic!("a shape layer") }; + let Fill::Gradient { stops, .. } = &sh.stroke.as_ref().unwrap().paint else { panic!("a gradient stroke") }; + assert_eq!(stops.as_slice(), [(1.0, Color::BLACK)]); +} + +/// A gradient stroke keeps every gradient field through `shape.edit`, and `shape.info` reports +/// them, so the shape swatches can change one setting without dropping the rest. +#[test] +fn gradient_stroke_round_trips_every_gradient_field() { + let mut s = session(100, 80, 16); + let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 6}})).unwrap()["layer"].as_u64().unwrap(); + let gradient = json!({"gradient": { + "stops": [[0.0, [0.25, 0.5, 0.75, 1.0]], [1.0, [1.0, 0.125, 0.0, 0.5]]], + "angle": 30.0, "scale": 50.0, "style": "radial", "reverse": true, + "opacityStops": [[1.0, 0.25], [0.0, 1.0]], "midpoints": [0.25], "offset": [0.125, -0.25], "dither": true, "align": false, + }}); + let r = s.execute("shape.edit", json!({"layer": id, "stroke": {"color": gradient}})).unwrap(); + let Some(Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align }) = shape(&s, id).stroke.map(|st| st.paint) + else { + panic!("a gradient stroke") + }; + assert_eq!(stops, vec![(0.0, Color::rgba(0.25, 0.5, 0.75, 1.0)), (1.0, Color::rgba(1.0, 0.125, 0.0, 0.5))]); + assert_eq!((angle, scale, style, reverse), (30.0, 0.5, GradientStyle::Radial, true)); + assert_eq!(opacity_stops, vec![(0.0, 1.0), (1.0, 0.25)], "opacity stops are sorted"); + assert_eq!((midpoints, offset, dither, align), (vec![0.25], (0.125, -0.25), true, false)); + let g = &r["stroke"]["fill"]["gradient"]; + assert_eq!((g["style"].as_str(), g["midpoints"].clone(), g["align"].as_bool()), (Some("radial"), json!([0.25]), Some(false))); + // A partial gradient keeps the defaults it always had. + s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": [[0, "#000000"], [1, "#ffffff"]]}}}})).unwrap(); + let Some(Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. }) = shape(&s, id).stroke.map(|st| st.paint) else { + panic!("a gradient stroke") + }; + assert_eq!((opacity_stops, midpoints, offset, dither, align), (Vec::new(), Vec::new(), (0.0, 0.0), false, true)); } #[test] @@ -941,6 +1009,74 @@ fn shape_info_bounds_of_curved_shapes_are_exact() { } } +/// A shape layer as a PSD import leaves it: Photoshop's rendering as the cache (deliberately not +/// what PhotoCraft would draw, a block of another colour, so a re-render shows). +fn add_imported_shape(s: &mut Session, r: Rect) -> LayerId { + s.edit("shape", |doc, active| { + let fmt = doc.pixel_format(); + let path = photocraft_vector::shapes::rect(f64::from(r.x0), f64::from(r.y0), f64::from(r.width()), f64::from(r.height())); + let mut cache = photocraft_raster::Surface::new(fmt); + cache.fill_rect(r, &photocraft_raster::from_rgba(&fmt, [0.9, 0.5, 0.1, 1.0])); + let sh = ShapeLayer { path, fill: Some(photocraft_doc::Fill::Solid(photocraft_color::Color::BLACK)), cache: Some(cache), ..Default::default() }; + let id = doc.insert_above(None, Layer::new("Rectangle 1", LayerContent::Shape(sh))); + *active = Some(id); + Ok(id) + }) + .unwrap() +} + +fn shape_layer(doc: &Document, id: LayerId) -> ShapeLayer { + match &doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.clone(), + other => panic!("not a shape layer: {}", other.kind_name()), + } +} + +fn orange_at(sh: &ShapeLayer, x: i32, y: i32) -> bool { + let c = sh.cache.as_ref().unwrap(); + let px = photocraft_raster::to_rgba(&c.format(), &c.pixel(x, y)); + (px[0] - 0.9).abs() < 0.01 && (px[1] - 0.5).abs() < 0.01 +} + +#[test] +fn moving_and_exporting_imported_shapes_keeps_their_rendering() { + for depth in [8, 16, 32] { + let mut s = session(100, 80, depth); + let id = add_imported_shape(&mut s, Rect::new(30, 20, 60, 40)); + // The Move tool: the same pixels, moved; the path moves with them. + s.execute("layer.translate", json!({"layer": id.0, "dx": 5, "dy": -3})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(35, 17, 65, 37), "depth {depth}"); + assert!(orange_at(&sh, 40, 20), "depth {depth}: not re-rendered"); + assert_eq!(sh.path.control_bounds().map(|b| (b.0.round(), b.1.round())), Some((35.0, 17.0)), "the path moves too"); + // Artboards to Files moves the board to the origin the same way. + s.execute("layer.new.artboard", json!({"rect": [20, 10, 60, 40]})).unwrap(); + let board = doc(&s).artboards()[0].0; + s.edit("into board", |doc, _| { + let l = doc.remove(id).unwrap(); + let Some(LayerContent::Group(g)) = doc.layer_mut(board).map(|b| &mut b.content) else { panic!("artboard group") }; + g.children.push(l); + Ok(()) + }) + .unwrap(); + let one = crate::artboard_cmds::artboard_document(doc(&s), board).unwrap().unwrap(); + let sh = shape_layer(&one, id); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(15, 7, 45, 27), "depth {depth}"); + assert!(orange_at(&sh, 20, 10), "depth {depth}: exported as rendered"); + } +} + +#[test] +fn a_shape_reaching_past_the_canvas_renders_again_when_moved() { + let mut s = session(100, 80, 8); + // Cut at the canvas's right edge (100 px), so its pixels can't simply be shifted back in. + let id = add_imported_shape(&mut s, Rect::new(80, 20, 120, 40)); + s.execute("layer.translate", json!({"layer": id.0, "dx": -30, "dy": 0})).unwrap(); + let sh = shape_layer(doc(&s), id); + assert!(!orange_at(&sh, 60, 30), "re-rendered from the path (black fill)"); + assert_eq!(sh.cache.as_ref().unwrap().content_bounds(), Rect::new(50, 20, 90, 40), "the whole shape shows"); +} + #[test] fn shape_create_negative_size_rect_covers_the_ellipse_box() { // Issue #4203: a rectangle with a negative width spanned x 50..70 while the ellipse and @@ -1070,3 +1206,35 @@ fn combine_shapes_fails_gracefully() { assert!(s.execute("layer.combineShapes.unite", json!({})).is_err()); assert_eq!(*doc(&s), before); } + +#[test] +fn triangles_and_polygons_take_a_corner_radius_kept_through_edits_and_scaling() { + let mut s = session(200, 200, 8); + let r = s.execute("shape.create", json!({"kind": "polygon", "rect": [20, 20, 120, 120], "sides": 3, "cornerRadius": 12})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + let live = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.live.clone(), + _ => None, + }; + let path_len = |s: &Session| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Shape(sh) => sh.path.subpaths[0].knots.len(), + _ => 0, + }; + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 3, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 6, "every vertex rounded"); + // Other edits keep the radius; a radius edit regenerates the path; 0 makes it sharp again. + s.execute("shape.edit", json!({"layer": id, "sides": 6})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { sides: 6, corner_radius, .. }) if corner_radius == 12.0)); + assert_eq!(path_len(&s), 12); + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 0})).unwrap(); + assert_eq!(path_len(&s), 6); + // Hostile values never panic: negative and non-finite radii are clamped or ignored. + for bad in [json!(-5), json!(1e300), json!("x")] { + let _ = s.execute("shape.edit", json!({"layer": id, "cornerRadius": bad})); + } + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if corner_radius.is_finite() && corner_radius >= 0.0)); + // Scaling the layer by half halves the radius, as for a rectangle's radii. + s.execute("shape.edit", json!({"layer": id, "cornerRadius": 10})).unwrap(); + s.execute("shape.edit", json!({"layer": id, "transform": [0.5, 0, 0, 0.5, 0, 0]})).unwrap(); + assert!(matches!(live(&s), Some(LiveShape::Polygon { corner_radius, .. }) if (corner_radius - 5.0).abs() < 1e-9)); +} diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index ddee127f2b..9917522d06 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -119,6 +119,10 @@ fn new_blank(s: &mut Session, _p: &Value) -> Result { } let fps = doc.timeline.as_ref().map_or(30.0, |t| t.fps); let n = doc.timeline.as_ref().map_or(1, |t| t.duration).max(1); + // A loaded timeline's duration bypasses the validation of timeline.create / setProps. + if n > crate::timeline_cmds::MAX_DURATION { + return Err(EngineError::Other(format!("the timeline is {n} frames; a video layer is limited to {} frames", crate::timeline_cmds::MAX_DURATION))); + } let frames: Vec = (0..n).map(|_| Surface::new(fmt)).collect(); let name = doc.next_layer_name("Video Layer"); let mut l = Layer::raster(name, fmt); @@ -216,7 +220,7 @@ fn rasterize(s: &mut Session, _p: &Value) -> Result { /// Frames loaded from a file or an image sequence. struct Footage { frames: Vec, - /// The frame rate the footage itself implies (a single animated GIF's delays); `None` for + /// The frame rate the footage itself implies (a single animated GIF's or WebP's delays); `None` for /// stills and sequences, which carry none. fps: Option, } @@ -249,7 +253,17 @@ fn fps_from_delays(delays: &[u32]) -> Option { best.map(|(d, _)| 1000.0 / d as f32) } -/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF), or a +/// `photocraft_io::import_gif_frames` / `import_webp_frames`. +type ImportFrames = fn( + &str, + &[u8], + &mut dyn FnMut(photocraft_doc::Document, u32) -> std::result::Result<(), photocraft_io::IoError>, +) -> std::result::Result; + +/// Frame storage one footage import may keep (whole tiles, all frames together). +const MAX_FOOTAGE_BYTES: u64 = 2 << 30; + +/// 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 { @@ -274,18 +288,46 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result = Vec::with_capacity(files.len()); let mut delays = Vec::new(); + // Each frame stores whole tiles however little it draws, so count the tiles kept so far. + let mut kept_bytes = 0u64; + let mut keep = |frames: &mut Vec, 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 { + return Err(format!("the footage has more than {} frames", crate::timeline_cmds::MAX_DURATION)); + } + let tile = u64::from(photocraft_geom::TILE_SIZE.unsigned_abs()).pow(2) * surface.format().bytes_per_pixel() as u64; + kept_bytes = kept_bytes.saturating_add(tile.saturating_mul(surface.tile_count() as u64)); + 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(()) + }; for f in &files { let bytes = std::fs::read(f).map_err(|e| EngineError::Other(format!("read `{}`: {e}", f.display())))?; let name = f.to_string_lossy(); - if photocraft_codecs::detect(&bytes) == Some(photocraft_codecs::Format::Gif) { - photocraft_io::import_gif_frames(&name, &bytes, &mut |doc, delay_ms| { - frames.push(crate::file_cmds::flattened(&doc, fmt)); + // A GIF (a still one is its only frame) or an animated WebP brings every frame. + let animation = match photocraft_codecs::detect(&bytes) { + Some(photocraft_codecs::Format::Gif) => Some(photocraft_io::import_gif_frames as ImportFrames), + Some(photocraft_codecs::Format::WebP) if photocraft_codecs::webp_is_animated(&bytes) => Some(photocraft_io::import_webp_frames as ImportFrames), + _ => None, + }; + if let Some(import) = animation { + let mut over = None; + let done = import(&name, &bytes, &mut |doc, delay_ms| { + if let Err(msg) = keep(&mut frames, crate::file_cmds::flattened(&doc, fmt)) { + over = Some(msg); + return Err(photocraft_io::IoError::Cancelled); + } delays.push(delay_ms); Ok(()) - }) - .map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?; + }); + if let Some(msg) = over { + return Err(EngineError::Other(format!("`{}`: {msg}", f.display()))); + } + done.map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?; continue; } let mut doc = crate::file_cmds::import(&name, &bytes)?; @@ -293,9 +335,10 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result { Ok(json!({"layers": n})) } +/// The document's name without its extension, as a file name: a native document keeps whatever +/// name its file declares, parent-directory components included, and the frames are written +/// beside the chosen folder, not wherever the name leads. +fn safe_stem(name: &str) -> String { + let leaf = name.rsplit(['/', '\\']).next().unwrap_or(name); + let stem = leaf.rsplit_once('.').map_or(leaf, |(a, _)| a).trim(); + if stem.is_empty() || stem.chars().all(|c| c == '.') { "video".to_string() } else { stem.to_string() } +} + /// Export › Render Video: composite the whole document at each timeline frame and write an image /// sequence (`{dir}/{stem}_NNNN.{format}`). Clean-room; no proprietary codec. fn render_video(s: &mut Session, p: &Value) -> Result { @@ -437,7 +489,7 @@ pub fn render_video_with( // Only the GIF delay reads the rate (frames come from the duration); rates under 1 fps are // valid timelines, so only an unusable loaded rate falls back to 30. let fps = base.timeline.as_ref().map(|t| t.fps).filter(|f| f.is_finite() && *f > 0.0).unwrap_or(30.0); - let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); + let stem = safe_stem(&base.name); let fmt = base.pixel_format(); let bounds = base.bounds(); let (w, h) = (bounds.width() as usize, bounds.height() as usize); @@ -482,7 +534,8 @@ pub fn render_video_with( } // Image sequence. - let opts = photocraft_io::ExportOptions::default(); + // PSD / PSB frames would embed the files linked smart objects name: not for a restricted session. + let opts = photocraft_io::ExportOptions { read_linked_files: s.authorize.is_none(), ..Default::default() }; let mut files = Vec::new(); for f in 0..frames { let mut doc = base.clone(); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index c97eb3b18d..3560c5f2c3 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -399,6 +399,80 @@ fn video_frames_to_layers_takes_every_gif_frame() { assert_eq!(r["layers"], 4); } +// ------------------------------------------------------------------ animated WebP footage (#1198) + +/// An animated WebP of solid `w`x`h` frames: one `(colour, duration in ms)` each, every frame a +/// lossless still in an ANMF chunk. +fn write_webp(path: &str, w: u32, h: u32, frames: &[([u8; 3], u32)]) { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, encode}; + let chunk = |id: &[u8; 4], data: &[u8]| { + let mut c = id.to_vec(); + c.extend_from_slice(&(data.len() as u32).to_le_bytes()); + c.extend_from_slice(data); + if data.len() % 2 == 1 { + c.push(0); + } + c + }; + let u24 = |v: u32| v.to_le_bytes()[..3].to_vec(); + let mut body = b"WEBP".to_vec(); + body.extend(chunk(b"VP8X", &[[0x02, 0, 0, 0].as_slice(), &u24(w - 1), &u24(h - 1)].concat())); + body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); + for &(c, ms) in frames { + let img = Image::from_u8(w, h, ChannelLayout::Rgb, c.repeat((w * h) as usize)).unwrap(); + let still = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap(); + let at = still.windows(4).position(|w| w == b"VP8L").unwrap(); + let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize; + let anmf = [[0; 6].as_slice(), &u24(w - 1), &u24(h - 1), &u24(ms), &[0x02], &chunk(b"VP8L", &still[at + 8..at + 8 + len])].concat(); + body.extend(chunk(b"ANMF", &anmf)); + } + let mut out = b"RIFF".to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend(body); + std::fs::write(path, out).unwrap(); +} + +#[test] +fn an_animated_webp_becomes_a_video_layer_with_every_frame() { + let dir = tmpdir("webpin"); + let path = format!("{dir}/anim.webp"); + write_webp(&path, 16, 12, &[([255, 0, 0], 50), ([0, 255, 0], 50), ([0, 0, 255], 80)]); + let mut s = session(); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path})).unwrap(); + assert_eq!(r["frames"], 3); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(frame_rgb(&s, id, 0), [1.0, 0.0, 0.0]); + assert_eq!(frame_rgb(&s, id, 1), [0.0, 1.0, 0.0]); + assert_eq!(frame_rgb(&s, id, 2), [0.0, 0.0, 1.0]); + // The most common duration, 50 ms: 20 fps. + assert_eq!(s.active().unwrap().doc.timeline.as_ref().unwrap().fps, 20.0); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(id).is_none()); +} + +#[test] +fn video_frames_to_layers_takes_every_webp_frame() { + let dir = tmpdir("webpin-ftl"); + let path = format!("{dir}/anim.webp"); + write_webp(&path, 8, 8, &[([255, 0, 0], 40), ([0, 255, 0], 40), ([0, 0, 255], 40)]); + let mut s = session(); + let r = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap(); + assert_eq!(r["layers"], 3); +} + +#[test] +fn a_still_webp_is_one_frame_at_the_default_30_fps() { + let dir = tmpdir("webpin-still"); + let path = format!("{dir}/still.webp"); + let img = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(64)).unwrap(); + std::fs::write(&path, photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &Default::default()).unwrap()).unwrap(); + let mut s = session(); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path})).unwrap(); + assert_eq!(r["frames"], 1); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 30.0); +} + #[test] fn a_gif_rendered_by_render_video_loads_back_frame_for_frame() { let src = tmpdir("gifrt-src"); @@ -595,3 +669,14 @@ fn fps_from_delays_picks_most_common_and_breaks_ties_by_first_seen() { many.push(20); assert_eq!(fps_from_delays(&many), Some(50.0)); } + +#[test] +fn render_video_names_frames_after_the_leaf_of_the_document_name() { + assert_eq!(safe_stem("clip.mov"), "clip"); + assert_eq!(safe_stem("../../outside/clip.png"), "clip"); + assert_eq!(safe_stem("C:\\Users\\x\\clip.v2.gif"), "clip.v2"); + for hostile in ["..", "../..", "", "/", "a/..", ".hidden."] { + let stem = safe_stem(hostile); + assert!(!stem.contains(['/', '\\']) && !stem.is_empty() && stem.chars().any(|c| c != '.'), "{hostile:?} -> {stem:?}"); + } +} diff --git a/crates/engine/src/vp_cmds.rs b/crates/engine/src/vp_cmds.rs index 185930298a..eb713d9141 100644 --- a/crates/engine/src/vp_cmds.rs +++ b/crates/engine/src/vp_cmds.rs @@ -3,7 +3,7 @@ //! ([`photocraft_algo::vanishing`]). The edit lands on the active pixel layer, or on a new layer //! with `"newLayer": true` (Photoshop's advice for non-destructive Vanishing Point work). -use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_stroke, paste}; +use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_dabs, paste}; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_raster::Surface; @@ -13,6 +13,11 @@ use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; pub const VP: &str = "filter.vanishingPoint"; +/// Most planes a scene holds. +pub const MAX_PLANES: usize = 64; +/// Most clone strokes, and dabs over all of them, one run paints. +pub const MAX_CLONE_STROKES: usize = 4096; +pub const MAX_CLONE_DABS: usize = 200_000; fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: VP.into(), msg: msg.into() } @@ -29,6 +34,9 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { .and_then(Value::as_array) .filter(|a| !a.is_empty()) .ok_or_else(|| bad("pass `planes`: [{\"corners\":[[x,y]×4]}, {\"from\":0,\"edge\":\"top\",\"angle\":90}]"))?; + if planes.len() > MAX_PLANES { + return Err(bad(format!("at most {MAX_PLANES} planes"))); + } let center = [(canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0]; let corners = |v: &Value| -> Option<[[f64; 2]; 4]> { let a = v.get("corners")?.as_array()?; @@ -67,6 +75,12 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { }; let angle = pl.get("angle").and_then(Value::as_f64).unwrap_or(90.0); let depth = pl.get("depth").and_then(Value::as_f64).unwrap_or(1.0); + if !angle.is_finite() || !(0.0..=360.0).contains(&angle) { + return Err(bad(format!("plane {i}: `angle` must be 0..360 degrees"))); + } + if !depth.is_finite() || depth <= 0.0 || depth > 1000.0 { + return Err(bad(format!("plane {i}: `depth` must be above 0 and at most 1000"))); + } sc.tear_off(from, edge, angle, depth).ok_or_else(|| bad(format!("plane {i} would lie behind the camera")))?; } if sc.planes.iter().any(|p| p.homography().is_none()) { @@ -75,6 +89,88 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { Ok(sc) } +/// One stroke of `"clone"`. +#[derive(Clone, Debug, PartialEq)] +pub struct CloneSpec { + pub source: [f64; 2], + pub points: Vec<[f64; 2]>, + pub size: f64, + pub hardness: f64, + pub opacity: f64, +} + +/// Reads `"clone"`: finite points, at most [`MAX_CLONE_STROKES`] strokes and +/// [`MAX_CLONE_DABS`] dabs in all. +pub fn clone_specs(p: &Value) -> Result> { + let Some(v) = p.get("clone").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let strokes = v.as_array().ok_or_else(|| bad("`clone` is a list of strokes"))?; + if strokes.len() > MAX_CLONE_STROKES { + return Err(bad(format!("at most {MAX_CLONE_STROKES} clone strokes"))); + } + let finite = |q: [f64; 2]| q.iter().all(|v| v.is_finite() && v.abs() < 1e7); + let num = |c: &Value, k: &str, default: f64, lo: f64, hi: f64| -> Result { + match c.get(k) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(lo, hi)).ok_or_else(|| bad(format!("clone `{k}` must be a number"))), + } + }; + let mut dabs = 0usize; + let mut out = Vec::with_capacity(strokes.len()); + for (k, c) in strokes.iter().enumerate() { + let source = c.get("source").and_then(pt).filter(|q| finite(*q)).ok_or_else(|| bad(format!("clone {k}: `source` [x,y]")))?; + let raw = c.get("points").and_then(Value::as_array).ok_or_else(|| bad(format!("clone {k}: `points` [[x,y], …]")))?; + dabs = dabs.saturating_add(raw.len()); + if dabs > MAX_CLONE_DABS { + return Err(bad(format!("at most {MAX_CLONE_DABS} clone points in all"))); + } + let points = + raw.iter().map(|q| pt(q).filter(|q| finite(*q))).collect::>>().ok_or_else(|| bad(format!("clone {k}: every point is [x,y]")))?; + out.push(CloneSpec { + source, + points, + size: num(c, "size", 40.0, 1.0, 2000.0)?, + hardness: num(c, "hardness", 50.0, 0.0, 100.0)? / 100.0, + opacity: num(c, "opacity", 100.0, 0.0, 100.0)? / 100.0, + }); + } + Ok(out) +} + +/// Paints clone stroke `c` on `surf` inside `canvas`, or, with `work` (the image it samples), on +/// `work` and copies the pixels it changed to `surf`. Returns the dabs painted. +pub fn clone_into(sc: &Scene, surf: &mut Surface, work: Option<&mut Surface>, c: &CloneSpec, canvas: Rect) -> usize { + let Some(first) = c.points.first() else { return 0 }; + let Some(work) = work else { + return clone_dabs(sc, surf, c.source, *first, &c.points, c.size, c.hardness, c.opacity as f32, Some(canvas)).0; + }; + let before = work.clone(); + // The dabs are cut to the canvas before they touch `work` or `surf`: points up to 1e7 px off + // it would otherwise allocate tiles there (and reading their union back, without bound). + let (dabs, area) = clone_dabs(sc, work, c.source, *first, &c.points, c.size, c.hardness, c.opacity as f32, Some(canvas)); + copy_changed(surf, &before, work, area.intersect(&canvas)); + dabs +} + +/// Copies to `dst` the pixels of `area` where `after` differs from `before` (same format). +pub fn copy_changed(dst: &mut Surface, before: &Surface, after: &Surface, area: Rect) { + if area.is_empty() { + return; + } + let n = dst.format().channels(); + let (nw, ow, mut cur) = (after.read_region(area), before.read_region(area), dst.read_region(area)); + for ((c, a), b) in cur.chunks_exact_mut(n).zip(nw.chunks_exact(n)).zip(ow.chunks_exact(n)) { + if a != b { + c.copy_from_slice(a); + } + } + dst.write_region(area, &cur); +} + +/// The image a `"newLayer"` run's clone strokes sample: the visible document. +pub fn visible_image(doc: &Document) -> Surface { + crate::file_cmds::flattened(doc, doc.pixel_format()) +} + fn has_layer(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; match d.active_layer.and_then(|id| d.doc.layer(id)).map(|l| &l.content) { @@ -84,8 +180,16 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> { } } +/// Pastes `src` (content in `rect`) onto plane `plane` of `sc` over `surf`, as the command's +/// `"paste"` does. Returns the area the paste covers. +pub fn paste_onto(sc: &Scene, surf: &mut Surface, plane: usize, src: &Surface, rect: Rect, at: [f64; 2], width: f64) -> Result { + let Some(out) = paste(sc, plane, src, rect, at, width)? else { return Ok(Rect::EMPTY) }; + over(surf, &out); + Ok(out.content_bounds()) +} + /// Composites `top` over `dst` (premultiplied source-over), same format. -fn over(dst: &mut Surface, top: &Surface) { +pub fn over(dst: &mut Surface, top: &Surface) { let b = top.content_bounds(); if b.is_empty() { return; @@ -134,20 +238,31 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result { let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.01, 10.0); pastes.push((plane, surf, rect, at, width)); } - let clones: Vec = p.get("clone").and_then(Value::as_array).cloned().unwrap_or_default(); + let clones = clone_specs(p)?; let new_layer = p.get("newLayer").and_then(Value::as_bool).unwrap_or(false); + // The layer the mode was opened on (the UI passes it: other layers can be picked meanwhile). + let layer = crate::commands::explicit_layer(s, p)?; let mut dabs = 0usize; let target = s.edit("Vanishing Point", |doc: &mut Document, active| { + if let Some(l) = layer + && doc.layer(l).is_none() + { + return Err(EngineError::NoLayer(l)); + } + let on = layer.or(*active); let id = if new_layer { let l = Layer::raster(doc.next_layer_name("Vanishing Point"), doc.pixel_format()); - let nid = doc.insert_above(*active, l); + let nid = doc.insert_above(on, l); *active = Some(nid); nid } else { - active.ok_or_else(|| EngineError::Other("no active layer".into()))? + // The painted layer becomes active, so Fade and a colour-channel target follow it. + let id = on.ok_or_else(|| EngineError::Other("no active layer".into()))?; + *active = Some(id); + id }; // A new layer clones from the visible image; otherwise from the layer itself. - let base: Option = new_layer.then(|| crate::file_cmds::flattened(doc, doc.pixel_format())); + let base: Option = new_layer.then(|| visible_image(doc)); let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if locks.all || locks.pixels { @@ -155,40 +270,16 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result { } let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Vanishing Point needs a pixel layer".into()))?; for (plane, src, rect, at, width) in &pastes { - if let Some(out) = paste(&sc, *plane, src, *rect, *at, *width)? { - over(surf, &out); - } + paste_onto(&sc, surf, *plane, src, *rect, *at, *width)?; } - for (k, c) in clones.iter().enumerate() { - let source = c.get("source").and_then(pt).ok_or_else(|| bad(format!("clone {k}: `source` [x,y]")))?; - let points: Vec<[f64; 2]> = c.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(pt).collect()).unwrap_or_default(); - let size = c.get("size").and_then(Value::as_f64).unwrap_or(40.0).clamp(1.0, 2000.0); - let hardness = c.get("hardness").and_then(Value::as_f64).unwrap_or(50.0).clamp(0.0, 100.0) / 100.0; - let opacity = (c.get("opacity").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.0, 100.0) / 100.0) as f32; - // Sample from the original pixels (merged with what's been pasted). - let mut work = match &base { - Some(b) => { - let mut w = b.clone(); - over(&mut w, surf); - w - } - None => surf.clone(), - }; - let before = work.clone(); - dabs += clone_stroke(&sc, &mut work, source, &points, size, hardness, opacity); - // Copy back only what the stroke changed. - let b = work.content_bounds().union(&before.content_bounds()); - if !b.is_empty() { - let (nw, ow) = (work.read_region(b), before.read_region(b)); - let n = surf.format().channels(); - let mut cur = surf.read_region(b); - for i in 0..cur.len() / n { - if nw[i * n..(i + 1) * n] != ow[i * n..(i + 1) * n] { - cur[i * n..(i + 1) * n].copy_from_slice(&nw[i * n..(i + 1) * n]); - } - } - surf.write_region(b, &cur); - } + // A new layer samples the visible image (merged with what's been pasted) and takes only + // the pixels the strokes change; otherwise the strokes paint the layer itself. + let mut work = base.map(|mut w| { + over(&mut w, surf); + w + }); + for c in &clones { + dabs += clone_into(&sc, surf, work.as_mut(), c, canvas); } surf.prune(); Ok(id) @@ -211,7 +302,7 @@ pub fn specs() -> Vec { label: "Vanishing Point…", menu: &["Filter"], shortcut: Some("Cmd+Alt+V"), - params: r##"{"planes":[{"corners":[[x,y]×4]} | {"from":plane,"edge":"top|right|bottom|left","angle":deg=90,"depth":1}],"focalLength":px=0,"paste":[{"plane":0,"layer":id? (else the clipboard),"at":[u,v]=[0.25,0.25],"width":0..1=0.5}],"clone":[{"source":[x,y],"points":[[x,y]],"size":px=40,"hardness":0..100=50,"opacity":0..100=100}],"newLayer":bool=false}"##, + params: r##"{"planes":[{"corners":[[x,y]×4]} | {"from":plane,"edge":"top|right|bottom|left","angle":deg=90,"depth":1}],"focalLength":px=0,"paste":[{"plane":0,"layer":id? (else the clipboard),"at":[u,v]=[0.25,0.25],"width":0..1=0.5}],"clone":[{"source":[x,y],"points":[[x,y]],"size":px=40,"hardness":0..100=50,"opacity":0..100=100}],"newLayer":bool=false,"layer":id|name? (else the active layer)}"##, enabled: has_layer, run: vanishing_point, journal: true, @@ -282,4 +373,123 @@ mod tests { } assert!(s.execute(VP, json!({"planes": [{"corners": square}]})).is_ok()); } + + #[test] + fn rejects_hostile_clone_and_tear_off_params() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "depth": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let floor = json!({"corners": [[60, 60], [140, 60], [190, 190], [10, 190]]}); + let before = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); + for torn in [json!({"from": 0, "angle": 1e9}), json!({"from": 0, "depth": -1}), json!({"from": 0, "depth": 1e300})] { + let e = s.execute(VP, json!({"planes": [floor, torn]})).unwrap_err().to_string(); + assert!(e.contains("plane 1"), "{e}"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); + } + let many: Vec<[f64; 2]> = vec![[100.0, 150.0]; MAX_CLONE_DABS + 1]; + for clone in [ + json!([{"points": [[100, 150]]}]), + json!([{"source": [100, 180], "points": [[100, 150], "x"]}]), + json!([{"source": [100, 180], "points": [[100, 150]], "size": "big"}]), + json!([{"source": [100, 180], "points": many}]), + json!({"source": [100, 180]}), + ] { + let label: String = clone.to_string().chars().take(80).collect(); + assert!(s.execute(VP, json!({"planes": [floor], "clone": clone, "newLayer": true})).is_err(), "{label}"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); + } + let planes: Vec = std::iter::repeat_n(floor.clone(), MAX_PLANES + 1).collect(); + assert!(s.execute(VP, json!({"planes": planes})).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); + // A clone on a torn-off wall, at 32 bits, sampling the floor. + let r = s + .execute(VP, json!({"planes": [floor, {"from": 0, "edge": "top", "angle": 90, "depth": 0.5}], "clone": [{"source": [100, 150], "points": [[100, 50], [104, 50]], "size": 8}]})) + .unwrap(); + assert_eq!(r["planes"].as_array().unwrap().len(), 2); + assert!(r["dabs"].as_u64().unwrap() >= 1, "{r}"); + } + + #[test] + fn new_layer_clone_stays_on_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#40c040"})).unwrap(); + // A red stripe where the first dab samples, so cloning it changes pixels. + s.edit("stripe", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(140, 80, 160, 120), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]); + // The second dab lies left of the canvas and samples the canvas 50 px to its right; the + // third is far off it (its box joined with the first's once asked for petabytes). + for points in [json!([[100, 100], [-30, 100]]), json!([[100, 100], [-30, 100], [9_000_000, 9_000_000]])] { + let r = s.execute(VP, json!({"planes": planes, "newLayer": true, "clone": [{"source": [150, 100], "points": points, "size": 20}]})).unwrap(); + let l = s.active().unwrap().doc.layer(LayerId(r["layer"].as_u64().unwrap())).unwrap().surface().unwrap(); + let b = l.content_bounds(); + assert!(!b.is_empty() && Rect::new(0, 0, 200, 200).contains_rect(&b), "{b:?}"); + assert!(l.rgba(100, 100)[0] > 0.9, "{:?}", l.rgba(100, 100)); + s.undo(); + } + } + + /// Without a new layer the strokes paint the layer itself, and only inside the canvas, as in + /// Photoshop: a dab off it painted pixels there (and allocated tiles for every dab, up to + /// 1e7 px away). + #[test] + fn layer_clone_stays_on_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#40c040"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]); + // The second dab lies left of the canvas and samples the canvas 50 px to its right; the + // third is far off it. + let points = json!([[100, 100], [-30, 100], [9_000_000, 9_000_000]]); + let r = s.execute(VP, json!({"planes": planes, "clone": [{"source": [150, 100], "points": points, "size": 20}]})).unwrap(); + assert_eq!(r["dabs"].as_u64(), Some(1), "{r}"); + let b = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds(); + assert!(Rect::new(0, 0, 200, 200).contains_rect(&b), "{b:?}"); + } + + #[test] + fn layer_param_paints_that_layer_not_the_active_one() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + s.execute("layer.new.layer", json!({"name": "floor"})).unwrap(); + s.execute("edit.fill", json!({"color": "#40c040"})).unwrap(); + s.edit("stripe", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(140, 80, 160, 120), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let floor = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "other"})).unwrap(); + let other = s.active().unwrap().active_layer.unwrap(); + let planes = json!([{"corners": [[0, 0], [200, 0], [200, 200], [0, 200]]}]); + let clone = json!([{"source": [150, 100], "points": [[100, 100]], "size": 20}]); + let surf = |s: &Session, id| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone(); + let r = s.execute(VP, json!({"planes": planes, "clone": clone, "layer": floor.0})).unwrap(); + assert_eq!(r["layer"].as_u64(), Some(floor.0)); + assert!(surf(&s, floor).rgba(100, 100)[0] > 0.9, "{:?}", surf(&s, floor).rgba(100, 100)); + assert!(surf(&s, other).content_bounds().is_empty(), "the active layer stays untouched"); + s.undo(); + // A new layer goes right above the named layer. + let r = s.execute(VP, json!({"planes": planes, "clone": clone, "layer": floor.0, "newLayer": true})).unwrap(); + let d = &s.active().unwrap().doc; + let ids: Vec = d.layers.iter().map(|l| l.id.0).collect(); + let at = |id: u64| ids.iter().position(|&i| i == id).unwrap(); + assert_eq!(at(r["layer"].as_u64().unwrap()), at(floor.0) + 1, "{ids:?}"); + s.undo(); + let before = s.active().unwrap().doc.clone(); + assert!(s.execute(VP, json!({"planes": planes, "clone": clone, "layer": 999_999})).is_err()); + assert!(s.execute(VP, json!({"planes": planes, "clone": clone, "layer": 999_999, "newLayer": true})).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + } } diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 57d6081c66..6a98b608fb 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -71,10 +71,17 @@ pub fn smart_warp_doc_space(layer: &Layer) -> Option { } /// Parses the warp params: a full `"warp"` object, or `"style"` (+ `"bend"`, `"hDistort"`, -/// `"vDistort"`, `"vertical"`), `"mesh"` and `"grid"` over `rect`. +/// `"vDistort"`, `"vertical"`; `"values"` for a cylinder), `"mesh"` and `"grid"` over `rect`. pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { if let Some(w) = p.get("warp") { - let w: Warp = serde_json::from_value(w.clone()).map_err(|e| bad(cmd, format!("bad `warp`: {e}")))?; + let mut w: Warp = serde_json::from_value(w.clone()).map_err(|e| bad(cmd, format!("bad `warp`: {e}")))?; + // A cylinder is its values: the mesh is rebuilt from them, whatever was passed. + if w.style == WarpStyle::Cylinder { + if w.vertical || w.h_distort != 0.0 || w.v_distort != 0.0 { + return Err(bad(cmd, CYLINDER_SHAPE)); + } + w = Warp::cylinder(&w.values, w.bounds).ok_or_else(|| bad(cmd, CYLINDER_VALUES))?; + } if w.mesh.as_ref().is_some_and(|m| !m.is_valid()) { return Err(bad(cmd, "malformed warp mesh")); } @@ -90,6 +97,13 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { w.h_distort = num("hDistort", 0.0); w.v_distort = num("vDistort", 0.0); w.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false); + if style == WarpStyle::Cylinder { + if w.vertical || w.h_distort != 0.0 || w.v_distort != 0.0 { + return Err(bad(cmd, CYLINDER_SHAPE)); + } + let values: Option> = p.get("values").and_then(Value::as_array).and_then(|a| a.iter().map(Value::as_f64).collect()); + return values.and_then(|v| Warp::cylinder(&v, rect)).ok_or_else(|| bad(cmd, CYLINDER_VALUES)); + } if style == WarpStyle::Custom { let mesh = match p.get("mesh") { Some(m) => { @@ -110,6 +124,47 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { Ok(w) } +const CYLINDER_VALUES: &str = + "a cylinder needs `values`: seven numbers [left, bottom, right, top, top depth, bottom depth, wrap 0..1], the right silhouette right of the left one"; +const CYLINDER_SHAPE: &str = "a cylinder is horizontal, with no hDistort or vDistort"; + +/// A document-space cylinder `w` stored on a smart object, whose placement's inverse is `inv` and +/// whose warp box `w.bounds` lands on `sb` in source space. A placement that only scales and +/// moves keeps it a cylinder (written to PSD as one): the silhouettes and edges map, and the +/// ellipses' depth, in units of the half axis, follows the change of aspect. Otherwise (rotated, +/// skewed, flipped, in perspective) it becomes its mesh, a custom warp. +fn cylinder_in_source(w: &Warp, inv: &Homography, sb: [f64; 4]) -> Option { + let m = inv.0; + let axis_aligned = m[8] != 0.0 && m[1] == 0.0 && m[3] == 0.0 && m[6] == 0.0 && m[7] == 0.0; + let (sx, sy) = (m[0] / m[8], m[4] / m[8]); + if axis_aligned && sx > 0.0 && sy > 0.0 && w.values.len() == 7 { + let v = &w.values; + let (x, y) = (|v: f64| sx * v + m[2] / m[8], |v: f64| sy * v + m[5] / m[8]); + let k = sy / sx; + if let Some(c) = Warp::cylinder(&[x(v[0]), y(v[1]), x(v[2]), y(v[3]), v[4] * k, v[5] * k, v[6]], sb) { + return Some(c); + } + } + let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(inv, p)); + Some(Warp::custom(mesh, sb)) +} + +/// Whether the custom warp `w` is exactly what Warp mode shows for `layer`'s stored cylinder +/// ([`smart_warp_doc_space`]: its mesh over the same box), so committing it untouched changes +/// nothing. Stored as a custom warp instead, the cylinder would be lost, as a PSD keeps it only +/// as `warpCylinder` (#4112). +fn is_stored_cylinder(layer: &Layer, w: &Warp) -> bool { + let LayerContent::Smart(sm) = &layer.content else { return false }; + if w.style != WarpStyle::Custom || sm.warp.as_ref().is_none_or(|s| s.style != WarpStyle::Cylinder) { + return false; + } + let (Some(shown), Some(m)) = (smart_warp_doc_space(layer), w.mesh.as_ref()) else { return false }; + let Some(o) = shown.mesh.as_ref() else { return false }; + let tol = 1e-6 * (1.0 + o.points.iter().flatten().chain(&shown.bounds).fold(0.0f64, |a, v| a.max(v.abs()))); + let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol); + o.us == m.us && o.vs == m.vs && near(&shown.bounds, &w.bounds) && near(o.points.as_flattened(), m.points.as_flattened()) +} + /// A Background is turned into a normal layer by the caller first /// ([`crate::extra_cmds::background_to_layer`]). /// `Err(Cancelled)` once `ctl` is cancelled: between the steps of a warp that don't check it. @@ -166,6 +221,7 @@ fn warp_layer( None => Some(Warp::custom(mesh, sb)), } } + WarpStyle::Cylinder => cylinder_in_source(w, &inv, sb), _ => Some(Warp { bounds: sb, mesh: None, ..w.clone() }), }; sm.warp = src_warp.filter(|w| !w.is_identity()); @@ -336,6 +392,7 @@ fn type_warp(cmd: &str, w: &Warp) -> Result Ok(None), WarpStyle::Custom if w.is_identity() => Ok(None), WarpStyle::Custom => Err(bad(cmd, "type can only take a warp style (arc, flag, …); a custom mesh needs rasterized type")), + WarpStyle::Cylinder => Err(bad(cmd, "type can only take a warp style (arc, flag, …); a cylinder needs rasterized type")), s => Ok(Some(photocraft_doc::text::TextWarp { style: s.psd_name().into(), value: clamp(w.bend), @@ -355,7 +412,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result< } s.edit("Warp", |doc, _| { let group = crate::transform_cmds::group_locks(doc, id); - let snapshot = doc.clone(); + let (dpi, format) = (doc.resolution_dpi, doc.pixel_format()); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let locks = l.locks.union(group); if locks.position || locks.all { @@ -363,7 +420,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result< } let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other("not a type layer".into())) }; t.warp = tw.clone(); - crate::type_cmds::refresh(&snapshot, t); + crate::type_cmds::refresh_at(dpi, format, t); Ok(()) })?; Ok(json!({"layer": id.0, "changed": true, "warp": tw})) @@ -379,6 +436,10 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result { if w.is_identity() && !is_smart { return Ok(json!({"layer": id.0, "changed": false})); } + // An untouched cylinder (Warp mode, then Enter) stays as it is, with no history step. + if p.get("mesh").is_none() && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| is_stored_cylinder(l, &w)) { + return Ok(json!({"layer": id.0, "changed": false})); + } let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let content_knots = p.get("mesh").is_some() && p.get("warp").is_none(); // A background job when started with `Session::start` (as Free Transform, #4218): the warp of @@ -589,7 +650,7 @@ fn grid(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px; on a smart object `us`/`vs` span the source contents, so points equal to the placement are an identity warp even when it is skewed),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; + const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist|cylinder","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"values":[left,bottom,right,top,topDepth,bottomDepth,wrap]? (cylinder: silhouettes and edges in document px, the ellipses' depths in half-axis units, wrap 0..1; horizontal only),"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px; on a smart object `us`/`vs` span the source contents, so points equal to the placement are an identity warp even when it is skewed),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; const S: &str = r##"{"warp":{…}? (the warp being edited; returned split),"rect":[x0,y0,x1,y1]?,"at":[x,y]? (document point; default = middle of the patch)} — without `warp`, edits the active smart object's warp. A 1×1 custom mesh is divided at the thirds first (the guides drawn on the default grid), so splitWarpCrosswise on an identity custom warp returns a 4×4 mesh (knots at 0, 1/3, 1/2, 2/3 and 1), not a 2×2. A preset is fitted as one patch and split without that step."##; const G: &str = r##"{"warp":{…}? (the warp being edited; returned with the new mesh),"rect":[x0,y0,x1,y1]?,"size":n|"default"|"3"|"4"|"5" (square patches, 1..=64; 1 and "default" are the single-patch grid)} — without `warp`/`style`/`mesh`, edits the active smart object's warp. The surface is kept and the new lines are real splits at i/n."##; vec![ @@ -741,6 +802,147 @@ mod tests { assert!(s.execute("edit.transform.warp", json!({"warp": Warp::custom(mesh, [0.0, 0.0, 10.0, 10.0])})).is_err()); } + /// The seven values of a cylinder over the session's painted box (x 10..60, y 10..40). + const CYL: [f64; 7] = [10.0, 40.0, 60.0, 10.0, 0.3, 0.3, 0.5]; + + /// `style: "cylinder"` takes its seven values (#4112); anything that isn't a horizontal + /// cylinder is refused, never a silent no-op, and the layer is left alone. + #[test] + fn cylinder_style_needs_its_values() { + let mut s = session(8); + let before = active_surface(&s); + for p in [ + json!({"style": "cylinder"}), + json!({"style": "cylinder", "values": [10, 40, 60, 10, 0.3, 0.3]}), + json!({"style": "cylinder", "values": [60, 40, 10, 10, 0.3, 0.3, 0.5]}), + json!({"style": "cylinder", "values": [10, 40, 60, 10, 0.3, 0.3, "x"]}), + json!({"style": "cylinder", "values": "seven"}), + json!({"style": "cylinder", "values": CYL, "vertical": true}), + json!({"style": "cylinder", "values": CYL, "hDistort": 10}), + json!({"warp": {"style": "cylinder", "bounds": [10.0, 10.0, 60.0, 40.0]}}), + json!({"warp": {"style": "cylinder", "values": CYL, "bounds": [10.0, 10.0, 60.0, 40.0], "vertical": true}}), + json!({"warp": {"style": "cylinder", "values": CYL, "bounds": [10.0, 10.0, 60.0, 40.0], "hDistort": 10.0}}), + json!({"warp": {"style": "cylinder", "values": CYL, "bounds": [10.0, 10.0, 60.0, 40.0], "vDistort": -5.0}}), + ] { + let e = s.execute("edit.transform.warp", p.clone()).unwrap_err().to_string(); + assert!(e.contains("cylinder"), "{p}: {e}"); + } + assert_eq!(active_surface(&s), before); + // On type it is refused like a custom mesh: Warp Text has no cylinder. + s.execute("type.create", json!({"x": 10, "y": 50, "text": "Hi"})).unwrap(); + assert!(s.execute("edit.transform.warp", json!({"style": "cylinder", "values": CYL})).is_err()); + } + + #[test] + fn cylinder_warps_pixels_at_every_depth() { + for depth in [8u64, 16, 32] { + let mut s = session(depth); + let r = s.execute("edit.transform.warp", json!({"style": "cylinder", "values": CYL})).unwrap(); + assert_eq!(r["changed"], true, "{depth}"); + let after = active_surface(&s); + let b = after.content_bounds(); + // Between the silhouettes, the edges bowed down by the ellipses. + assert!(b.x0 >= 9 && b.x1 <= 61 && b.y0 >= 9 && b.y1 > 40, "{depth}: {b:?}"); + // The same cylinder as a full warp object (its mesh rebuilt from the values) agrees. + let mut t = session(depth); + let w = Warp::cylinder(&CYL, [10.0, 10.0, 60.0, 40.0]).unwrap(); + let mut garbled = w.clone(); + garbled.mesh = None; + t.execute("edit.transform.warp", json!({"warp": garbled})).unwrap(); + assert_eq!(active_surface(&t), after, "{depth}"); + } + } + + /// On a smart object the cylinder is stored in source space: still a cylinder (so it goes to + /// PSD as one) when the placement only scales and moves, its mesh when it rotates. + #[test] + fn smart_object_cylinder_is_stored_in_source_space() { + let stored = |s: &Session| { + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart") }; + sm.warp.clone().unwrap() + }; + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": CYL, "rect": [10, 10, 60, 40]})).unwrap(); + let w = stored(&s); + assert_eq!(w.style, WarpStyle::Cylinder); + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layers[1].content else { panic!("smart") }; + let inv = crate::smart_cmds::placement(sm).inverse().unwrap(); + let src = |x: f64, y: f64| inv.apply(x, y); + let ((l, t), (r, bt)) = (src(10.0, 10.0), src(60.0, 40.0)); + for (a, b) in w.values.iter().zip([l, bt, r, t, 0.3, 0.3, 0.5]) { + assert!((a - b).abs() < 1e-9, "{:?}", w.values); + } + // Twice as wide: the silhouettes double, the ellipses' depth (in half axes) halves. + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 100})).unwrap(); + let wide = [-15.0, 40.0, 85.0, 10.0, 0.3, 0.3, 0.5]; + s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": wide, "rect": [-15, 10, 85, 40]})).unwrap(); + let w = stored(&s); + assert_eq!(w.style, WarpStyle::Cylinder); + assert!((w.values[4] - 0.6).abs() < 1e-9 && (w.values[5] - 0.6).abs() < 1e-9, "{:?}", w.values); + // The silhouettes land on the source's own edges (its contents span the scaled box). + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layers[1].content else { panic!("smart") }; + let inv = crate::smart_cmds::placement(sm).inverse().unwrap(); + assert!((w.values[0] - inv.apply(-15.0, 10.0).0).abs() < 1e-9 && (w.values[2] - inv.apply(85.0, 10.0).0).abs() < 1e-9, "{:?}", w.values); + // Rotated: a custom mesh with the same shape. + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("edit.transform", json!({"rotate": 30})).unwrap(); + s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": CYL, "rect": [10, 10, 60, 40]})).unwrap(); + let w = stored(&s); + assert_eq!(w.style, WarpStyle::Custom); + assert_eq!(w.mesh.as_ref().map(|m| (m.nx(), m.ny())), Some((13, 4))); + } + + /// Warp mode shows a stored cylinder as its mesh, and Enter commits that mesh untouched: the + /// smart object keeps its cylinder (else the next PSD save writes no warp), while a moved + /// point still makes it a custom warp. + #[test] + fn untouched_cylinder_committed_from_warp_mode_stays_a_cylinder() { + for scale in [100, 150] { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("edit.transform", json!({"scaleX": scale, "scaleY": 100})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let rect = warp_bounds(&s.active().unwrap().doc, s.active().unwrap().doc.layer(id).unwrap()); + let [x0, x1] = [f64::from(rect.x0), f64::from(rect.x1)]; + let values = [x0, 40.0, x1, 10.0, 0.3, 0.3, 0.5]; + s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": values, "rect": [rect.x0, 10, rect.x1, 40]})).unwrap(); + let stored = |s: &Session| { + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(id).unwrap().content else { panic!("smart") }; + sm.warp.clone().unwrap() + }; + let cylinder = stored(&s); + assert_eq!(cylinder.style, WarpStyle::Cylinder, "{scale}"); + let shown = smart_warp_doc_space(s.active().unwrap().doc.layer(id).unwrap()).unwrap(); + assert_eq!(shown.style, WarpStyle::Custom); + s.execute("select.all", json!({})).unwrap(); + let (sel, steps) = (s.active().unwrap().doc.selection.clone(), s.active().unwrap().history.past_len()); + let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": shown.bounds, "warp": shown, "interpolation": "bicubic"})).unwrap(); + assert_eq!(r["changed"], false, "{scale}"); + assert_eq!(stored(&s), cylinder, "{scale}: untouched"); + let st = s.active().unwrap(); + assert!(st.doc.selection == sel && st.history.past_len() == steps, "{scale}: no step, the selection stays"); + // The same mesh over another box is another warp. + let mut wider = shown.clone(); + wider.bounds[2] += 10.0; + s.execute("edit.transform.warp", json!({"layer": id.0, "rect": wider.bounds, "warp": wider})).unwrap(); + assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: other box"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(stored(&s), cylinder); + let mut moved = shown.clone(); + if let Some(m) = moved.mesh.as_mut() { + m.points[20][1] += 5.0; + } + s.execute("edit.transform.warp", json!({"layer": id.0, "rect": moved.bounds, "warp": moved})).unwrap(); + assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: edited"); + } + } + #[test] fn identity_and_bend_zero_are_no_ops_at_every_depth() { for depth in [8u64, 16, 32] { diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index f77a020819..121cfe36b8 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -19,7 +19,7 @@ use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::CommandSpec; -use crate::file_cmds::{join, native_doc, stem, write_file}; +use crate::file_cmds::{is_windows_device_name, join, native_doc, portable_device_name, stem, write_file}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -732,7 +732,7 @@ fn slice_file(name: &str) -> String { /// Generated filenames should remain usable on Windows even when a document or slice is named /// after a reserved device. Caller-supplied path components are still rejected below. fn portable_generated_stem(stem: String) -> String { - if is_windows_device_asset_name(&stem) { format!("{stem}_") } else { stem } + portable_device_name(stem) } fn html_escape(s: &str) -> String { @@ -1024,6 +1024,8 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u // ---------- Export Preferences / Quick Export ---------- +/// File › Export › Export Preferences…: sets the given `export.*` preferences through `prefs.set` +/// (validated like any other preference) and returns the whole section. fn export_preferences(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.exportPreferences"; let mut values = serde_json::Map::new(); @@ -1086,8 +1088,25 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { target } }; + // The name typed in the save dialog wins over Export Preferences' format: Quick Export as PNG + // renamed to `photo.jpeg` must write a JPEG, not PNG bytes upload checks refuse (#8519). + let fmt = match crate::file_cmds::extension(&path).as_deref() { + None => fmt, + Some("jpg" | "jpeg") => "jpg".into(), + Some(e @ ("png" | "gif" | "webp" | "exr")) => e.into(), + Some(e) => return Err(bad(cmd, format!("Quick Export writes PNG, JPEG, GIF, WebP or EXR, not .{e}; use Export As"))), + }; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); + if fmt == "exr" { + // EXR keeps floating-point scene values. The ordinary exporter converts to linear + // light; the web/sRGB pipeline would instead clamp HDR and negative values. + let opts = photocraft_io::ExportOptions { xmp: photocraft_io::XmpEmbed::None, ..Default::default() }; + let out = photocraft_io::export(&doc, "exr", &opts).map_err(other)?; + write_file(&path, &out.bytes)?; + crate::automate_cmds::fire_event(s, "export"); + return Ok(json!({"path": path, "format": fmt, "bytes": out.bytes.len(), "warnings": out.warnings})); + } let metadata = serde_json::to_value(prefs.metadata).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let mut o = serde_json::Map::new(); o.insert( @@ -1296,13 +1315,6 @@ fn asset_settings(spec: &AssetSpec) -> WebSettings { } } -fn is_windows_device_asset_name(component: &str) -> bool { - let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); - matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") - || stem.strip_prefix("COM").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) - || stem.strip_prefix("LPT").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) -} - /// Layer names and `default` folders are document data, so reject unsafe output components. fn asset_path_components(relative: &str) -> Result> { // Split both separator styles even on Unix, where a backslash would otherwise be a legal @@ -1316,7 +1328,7 @@ fn asset_path_components(relative: &str) -> Result> { || part.ends_with(' ') || part.contains(':') || part.contains('\0') - || is_windows_device_asset_name(part) + || is_windows_device_name(part) { return Err(other(format!("asset path {relative:?} has an unsafe component {part:?}"))); } @@ -1391,6 +1403,10 @@ fn asset_output_path(dir: &str, relative: &str) -> Result<(String, String)> { /// same file, the one higher in the Layers panel keeps it and the other is reported in `errors`. /// Names are compared ignoring case, as `Logo.png` and `logo.png` are one file on Windows and /// macOS. +/// +/// A layer is trimmed to its visible pixels; an artboard keeps its whole board, transparent edges +/// included, as File › Export › Artboards to Files writes it (Photoshop's Generator exports an +/// artboard asset at the board's bounds). pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { let dpi = f64::from(doc.resolution_dpi.max(1.0)); let mut defaults: Option> = None; @@ -1423,7 +1439,13 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { if written.contains(&key) { return Err(other(format!("{rel}: another layer already writes this file; rename one of the layers"))); } - let mut one = crate::layer_menu_cmds::layer_document(doc, *id)?; + let mut one = match crate::artboard_cmds::artboard_document(doc, *id)? { + // Like Photoshop's Generator, a board with nothing on it is refused rather + // than written as a blank file. + Some(board) if !has_pixels(&board) => return Err(other("the layer is empty")), + Some(board) => board, + None => crate::layer_menu_cmds::layer_document(doc, *id)?, + }; one.metadata = Default::default(); let (w0, h0) = (f64::from(one.size.width), f64::from(one.size.height)); // The layer's own size spec wins; default variants scale on top of it. @@ -1469,6 +1491,11 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { (files, errors) } +/// Whether any pixel of `doc` is not fully transparent. +fn has_pixels(doc: &Document) -> bool { + photocraft_compose::render(doc, doc.bounds()).px.iter().any(|q| q[3] > 0.0) +} + /// `/-assets`. pub fn assets_dir(doc_path: &str) -> String { let p = std::path::Path::new(doc_path); @@ -1476,6 +1503,8 @@ pub fn assets_dir(doc_path: &str) -> String { join(&dir, &format!("{}-assets", stem(doc_path))) } +/// File › Generate › Image Assets: toggles generation for the active document (or sets it with +/// `on`) and, when it is on and the output folder is known, writes the assets now. fn image_assets(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let id: DocId = d.doc.id; @@ -1505,7 +1534,7 @@ fn image_assets(s: &mut Session, p: &Value) -> Result { /// After a save: regenerate the document's image assets when Generate › Image Assets is on. pub fn on_saved(s: &Session, index: usize) -> Option { let d = s.documents().get(index)?; - if !s.file_menu.image_assets.contains(&d.doc.id) || cfg!(target_arch = "wasm32") { + if !s.file_menu.image_assets.contains(&d.doc.id) || !s.prefs().plug_ins.generator_enabled || cfg!(target_arch = "wasm32") { return None; } let dir = assets_dir(d.path.as_deref()?); @@ -1522,6 +1551,16 @@ pub fn specs() -> Vec { fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } + + /// Generate ▸ Image Assets needs Preferences ▸ Plug-ins ▸ Generator enabled, as in + /// Photoshop (where "Enable Generator" is unchecked until turned on). + fn generator_enabled(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if !s.prefs().plug_ins.generator_enabled { + return Err("Enable Generator in Preferences ▸ Plug-ins".into()); + } + Ok(()) + } vec![ spec!( "file.export.saveForWebLegacy", @@ -1537,7 +1576,7 @@ pub fn specs() -> Vec { "Export Preferences…", &["File", "Export"], None, - r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, + r##"{"quickExportFormat":"png|jpg|gif|webp|exr"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, |_| Ok(()), export_preferences ), @@ -1556,7 +1595,7 @@ pub fn specs() -> Vec { &["File", "Generate"], None, r##"{"on":bool? (default: toggle),"dir":folder? (default -assets next to the file)} → {enabled, files, errors}; layers named like "foo.png", "200% foo@2x.png", "48x48 icons/a.png8", "photo.jpg80%" are exported, now and after each save"##, - has_doc, + generator_enabled, image_assets ), ] diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index ea8fe40177..38fa82c188 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -167,6 +167,19 @@ fn reserved_document_and_slice_names_export_with_portable_names() { assert!(std::path::Path::new(result["html"].as_str().unwrap()).is_file()); } +#[test] +fn a_dotted_device_file_name_exports_without_the_device_stem() { + // `nul.v2_` would still be the NUL device on Windows; `nul_.v2` is a file. + let dir = tmp("reserved-dotted-name"); + let mut s = session(8); + s.execute("slice.new", json!({"rect": [0, 0, 16, 16], "name": "logo"})).unwrap(); + let path = format!("{dir}/nul.v2.png"); + let result = s.execute("file.export.saveForWebLegacy", json!({"path": path, "slices": "all", "format": "png24", "html": true})).unwrap(); + let html = result["html"].as_str().unwrap(); + assert!(html.ends_with("/nul_.v2.html"), "{html}"); + assert!(std::path::Path::new(html).is_file()); +} + #[test] fn platform_writer_failure_stops_export_and_preserves_saved_settings() { let mut s = session(8); @@ -466,6 +479,33 @@ fn quick_export_never_replaces_the_documents_own_file_unasked() { s.execute("prefs.reset", json!({"path": "export"})).unwrap(); } +/// #8519: Quick Export as PNG renamed to `photo.jpeg` in the save dialog wrote PNG bytes under a +/// JPEG name, which upload checks refuse. A Quick Export extension picks the format; another +/// image extension is refused; no extension keeps Export Preferences' format. +#[test] +fn quick_export_writes_the_format_its_path_names() { + let dir = tmp("quick_ext"); + let mut s = session(8); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "png"})).unwrap(); + for (name, format, magic) in [ + ("a.jpeg", "jpg", &b"\xFF\xD8\xFF"[..]), + ("b.JPG", "jpg", b"\xFF\xD8\xFF"), + ("c.gif", "gif", b"GIF8"), + ("d.webp", "webp", b"RIFF"), + ("e", "png", b"\x89PNG"), + ] { + let path = format!("{dir}/{name}"); + let r = s.execute("file.export.quickExport", json!({"path": path})).unwrap(); + assert_eq!(r["format"], json!(format), "{name}"); + assert!(std::fs::read(&path).unwrap().starts_with(magic), "{name} holds {format} data"); + } + let tif = format!("{dir}/f.tif"); + let e = s.execute("file.export.quickExport", json!({"path": tif})).unwrap_err().to_string(); + assert!(e.contains("Export As"), "{e}"); + assert!(!std::path::Path::new(&tif).exists()); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + #[test] fn same_file_sees_through_other_names_for_one_file() { let dir = tmp("same_file"); @@ -473,13 +513,13 @@ fn same_file_sees_through_other_names_for_one_file() { std::fs::write(&a, b"a").unwrap(); std::fs::write(&c, b"c").unwrap(); assert!(crate::file_cmds::same_file(&a, &a)); - assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); + assert!(crate::file_cmds::same_file(&a, format!("{dir}/./a.png"))); assert!(!crate::file_cmds::same_file(&a, &c)); assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); std::fs::hard_link(&a, &b).unwrap(); assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); #[cfg(windows)] - assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); + assert!(crate::file_cmds::same_file(&a, format!("{dir}/A.PNG")), "NTFS names ignore case"); } #[test] @@ -570,10 +610,35 @@ fn generator_naming_grammar() { assert!(parse_defaults("defaults are nice", 72.0).is_none()); } +#[test] +fn image_assets_needs_the_generator_preference() { + let dir = tmp("generator_pref"); + let mut s = session(8); + 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"); + assert!(!s.is_enabled("file.generate.imageAssets"), "unavailable until it is enabled"); + assert!(s.execute("file.generate.imageAssets", json!({"dir": dir})).is_err()); + + s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); + 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"); + + // 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"); +} + #[test] fn image_assets_are_generated_now_and_on_save() { let dir = tmp("assets"); let mut s = session(8); + // Preferences ▸ Plug-ins ▸ Generator enabled: the feature is opt-in. + s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); let id = s.active().unwrap().active_layer.unwrap(); s.execute("layer.renameLayer", json!({"layer": id.0, "name": "logo.png, 50% small/logo.gif, logo.jpg80%"})).unwrap(); let r = s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap(); @@ -605,6 +670,74 @@ fn image_assets_are_generated_now_and_on_save() { assert_eq!(decode(&files[0]).dimensions(), (128, 80)); } +/// 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. +#[test] +fn image_assets_keep_full_artboards() { + let dir = tmp("asset-artboards"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "name": "boards.psd", "background": "transparent"})).unwrap(); + s.edit("board", |doc, _| { + let fmt = doc.pixel_format(); + let mut logo = Layer::raster("logo.png", fmt); + logo.surface_mut().unwrap().fill_rect(Rect::new(12, 10, 20, 16), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let board = + photocraft_doc::Artboard { rect: Rect::new(4, 6, 44, 36), background: photocraft_doc::ArtboardBackground::Transparent, preset: String::new() }; + let group = photocraft_doc::Group { children: vec![logo], expanded: true, artboard: Some(board) }; + doc.layers = vec![Layer::new("board.png", LayerContent::Group(group))]; + Ok(()) + }) + .unwrap(); + + s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); + let r = s.execute("file.generate.imageAssets", json!({"on": true, "dir": dir})).unwrap(); + assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); + let board = decode(&format!("{dir}/board.png")); + assert_eq!(board.dimensions(), (40, 30), "the whole board, not its trimmed pixels"); + let px = board.to_rgba8(); + let at = |x: usize, y: usize| &px[(y * 40 + x) * 4..(y * 40 + x) * 4 + 4]; + assert_eq!(at(0, 0)[3], 0, "the board's empty corner stays transparent"); + assert_eq!(at(39, 29)[3], 0); + // The logo keeps its place on the board: document (12, 10) is board (8, 4). + assert!(at(8, 4)[0] > 250 && at(8, 4)[3] == 255, "{:?}", at(8, 4)); + assert_eq!(at(7, 4)[3], 0); + assert_eq!(decode(&format!("{dir}/logo.png")).dimensions(), (8, 6), "a layer on the board is still trimmed"); +} + +/// An artboard asset with nothing on the board (empty, or its contents all outside it) is +/// refused like an empty layer, not written as a blank board: Photoshop's Generator refuses a +/// zero-bounds pixmap before it pads to the board. +#[test] +fn image_assets_refuse_empty_artboards() { + let dir = tmp("asset-empty-artboards"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "name": "boards.psd", "background": "transparent"})).unwrap(); + s.edit("boards", |doc, _| { + let fmt = doc.pixel_format(); + let mut outside = Layer::raster("outside", fmt); + outside.surface_mut().unwrap().fill_rect(Rect::new(50, 40, 60, 46), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let board = |x0| photocraft_doc::Artboard { + rect: Rect::new(x0, 0, x0 + 20, 20), + background: photocraft_doc::ArtboardBackground::Transparent, + preset: String::new(), + }; + let empty = photocraft_doc::Group { children: vec![], expanded: true, artboard: Some(board(0)) }; + let off = photocraft_doc::Group { children: vec![outside], expanded: true, artboard: Some(board(24)) }; + doc.layers = vec![Layer::new("empty.png", LayerContent::Group(empty)), Layer::new("off.png", LayerContent::Group(off))]; + Ok(()) + }) + .unwrap(); + s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); + let r = s.execute("file.generate.imageAssets", json!({"on": true, "dir": dir})).unwrap(); + assert_eq!(r["files"], json!([]), "{r}"); + let errors = r["errors"].as_array().unwrap(); + assert_eq!(errors.len(), 2, "{r}"); + assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("the layer is empty")), "{r}"); + assert!(!std::path::Path::new(&format!("{dir}/empty.png")).exists()); + assert!(!std::path::Path::new(&format!("{dir}/off.png")).exists()); +} + /// #6614: two layers, or two default variants, naming one file never overwrite each other: the /// layer higher in the Layers panel keeps the file, whatever the case of its name, and the /// others are reported. @@ -995,3 +1128,38 @@ fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() { assert_eq!(decode(&path).dimensions(), (64, 48)); } } + +#[test] +fn quick_export_exr_preserves_linear_hdr_and_alpha() { + use photocraft_codecs::{ChannelLayout, Format, Image, SampleType}; + let dir = tmp("quick_exr_hdr"); + let samples = [-0.25, 2.0, 0.125, 1.0, 4.0, 0.25, 0.5, 0.5]; + let image = Image::from_f32(2, 1, ChannelLayout::Rgba, &samples).unwrap(); + let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &Default::default()).unwrap(); + let doc = photocraft_io::import("hdr.exr", &bytes).unwrap().document; + let mut s = Session::new(); + s.add_document(doc, Some(format!("{dir}/source.psd"))); + for convert in [true, false] { + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "exr", "quickExportLocation": "sameFolder", "convertToSrgb": convert})).unwrap(); + let result = s.execute("file.export.quickExport", json!({})).unwrap(); + assert_eq!(result["format"], "exr"); + let path = format!("{dir}/source.exr"); + assert_eq!(result["path"], path); + assert_eq!(result["bytes"].as_u64(), Some(std::fs::metadata(&path).unwrap().len())); + let decoded = decode(&path); + assert_eq!(decoded.sample_type(), SampleType::F32); + let actual = decoded.convert(ChannelLayout::Rgba, SampleType::F32).to_f32_samples().unwrap(); + for (got, expected) in actual.iter().zip(samples) { + assert!((got - expected).abs() < 0.0001, "EXR preserves scene values: {actual:?}"); + } + assert_eq!(actual.len(), samples.len()); + } + // The format preference must never silently overwrite the source EXR. + let own = format!("{dir}/source.exr"); + s.active_mut().unwrap().path = Some(own.clone()); + let before = std::fs::read(&own).unwrap(); + assert!(s.execute("file.export.quickExport", json!({})).is_err()); + assert_eq!(std::fs::read(&own).unwrap(), before); + s.execute("file.export.quickExport", json!({"path": format!("{dir}/explicit.exr")})).unwrap(); + assert_eq!(decode(&format!("{dir}/explicit.exr")).dimensions(), (2, 1)); +} diff --git a/crates/engine/tests/affinity.rs b/crates/engine/tests/it/affinity.rs similarity index 100% rename from crates/engine/tests/affinity.rs rename to crates/engine/tests/it/affinity.rs diff --git a/crates/engine/tests/artboard_export_names.rs b/crates/engine/tests/it/artboard_export_names.rs similarity index 100% rename from crates/engine/tests/artboard_export_names.rs rename to crates/engine/tests/it/artboard_export_names.rs diff --git a/crates/engine/tests/it/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs new file mode 100644 index 0000000000..7b72a6ff70 --- /dev/null +++ b/crates/engine/tests/it/atn_crystallize_cell_size.rs @@ -0,0 +1,151 @@ +//! #7669: strict ATN export must not replace a native fractional Crystallize cell size. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::{atn, descriptor::Value as D}; +use serde_json::{Value, json}; + +const CRYSTALLIZE: &str = "filter.pixelate.crystallize"; + +/// Procedural colours expose cell-boundary changes without relying on external images. +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 18, "depth": depth})).unwrap(); + s.edit("procedural fixture", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let bytes: Vec<_> = + (0..432).flat_map(|i| photocraft_raster::from_rgba(&format, [(i % 11) as f32 / 10.0, (i % 7) as f32 / 6.0, (i % 5) as f32 / 4.0, 1.0])).collect(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 24, 18), &bytes); + Ok(()) + }) + .unwrap(); + s +} + +/// Compare the actual flattened document rather than just the imported parameters. +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// Fractions remain valid native edits; strict integer ATN conversion must refuse them. +#[test] +fn fractional_cell_sizes_remain_native_and_are_refused_by_strict_export() { + let mut silently_exported = Vec::new(); + for depth in [8, 16, 32] { + for cell_size in [3.1, 5.4, 5.5, 5.6, 10.25, 299.9] { + let params = json!({"cellSize": cell_size}); + let action = Action::new("Fractional Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(CRYSTALLIZE, params).unwrap(); + let expected = pixels(&direct); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Fractional Crystallize"})).unwrap(); + assert_eq!(pixels(&native), expected); + + if cell_size == 5.4 { + let mut rounded = fixture(depth); + rounded.execute(CRYSTALLIZE, json!({"cellSize": 5})).unwrap(); + assert_ne!(pixels(&rounded), expected, "fixture must expose 5.4 vs 5: {depth}"); + } + if let Ok((bytes, skipped)) = atn_cmds::export("Crystallize", &[&action], &[], false) { + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(skipped.is_empty() && imported.unsupported.is_empty() && imported.warnings.is_empty()); + let exported = imported.actions[0].steps[0].1["cellSize"].clone(); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Fractional Crystallize"})).unwrap(); + let changed = pixels(&replay) != expected; + if cell_size == 5.4 { + assert!(changed, "baseline must expose the reported pixel loss: {depth}"); + } + silently_exported.push(format!("depth {depth}: {cell_size} -> {exported}, pixels changed: {changed}")); + } + } + } + assert!(silently_exported.is_empty(), "strict export silently accepted fractional cell sizes:\n{}", silently_exported.join("\n")); +} + +/// Integer sizes, bounds and omitted defaults retain their binary descriptor and playback. +#[test] +fn integer_cell_sizes_and_defaults_preserve_playback() { + for depth in [8, 16, 32] { + let mut cases = vec![Value::Null, json!({}), json!({"target": "pixels"}), json!({"cellSize": 5.0})]; + cases.extend([3, 5, 10, 100, 300].map(|cell_size| json!({"cellSize": cell_size}))); + for params in cases { + let action = Action::new("Integer Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + let mut direct = fixture(depth); + direct.execute(CRYSTALLIZE, params.clone()).unwrap(); + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": "Integer Crystallize"})).unwrap(); + assert_eq!(pixels(&native), pixels(&direct)); + + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + if let Some(cell_size) = params.get("cellSize").and_then(Value::as_f64) { + assert!(matches!(descriptor.get("ClSz"), Some(D::Integer(n)) if f64::from(*n) == cell_size)); + } + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + assert_eq!(imported.actions[0].steps.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, CRYSTALLIZE); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": "Integer Crystallize"})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct), "depth {depth}: {params}"); + } + } +} + +/// Skipping is explicit and reports exactly which edits were lost, preserving supported order. +#[test] +fn skipping_fractional_cell_sizes_reports_action_and_step() { + let action = + Action::new("Mixed Crystallize", [5.0, 5.4, 10.0, 10.25].into_iter().map(|cell_size| (CRYSTALLIZE.into(), json!({"cellSize": cell_size}))).collect()); + let error = atn_cmds::export("Crystallize", &[&action], &[], false).unwrap_err(); + assert!(error.contains("step 2 of `Mixed Crystallize`"), "{error}"); + assert!(error.contains(CRYSTALLIZE) && error.contains("skipUnsupported"), "{error}"); + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], true).unwrap(); + assert_eq!( + skipped, + vec![ + atn_cmds::Skipped { action: "Mixed Crystallize".into(), step: 1, id: CRYSTALLIZE.into() }, + atn_cmds::Skipped { action: "Mixed Crystallize".into(), step: 3, id: CRYSTALLIZE.into() }, + ] + ); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + let steps = &imported.actions[0].steps; + assert_eq!(steps.len(), 2); + for (step, cell_size) in steps.iter().zip([5.0, 10.0]) { + assert_eq!(step.0, CRYSTALLIZE); + assert_eq!(step.1["cellSize"].as_f64(), Some(cell_size)); + } +} + +/// The plain event still cannot carry mask/channel targets or nonnumeric cell sizes. +#[test] +fn non_pixel_targets_and_malformed_fields_are_still_refused() { + for params in [ + json!({"cellSize": 5, "target": "mask"}), + json!({"cellSize": 5, "target": "quickMask"}), + json!({"cellSize": 5, "target": "channel"}), + json!({"cellSize": 5, "target": null}), + json!({"cellSize": null}), + json!({"cellSize": "5.4"}), + json!({"cellSize": 2147483648_i64}), + json!({"cellSize": -2147483649_i64}), + json!({"cellSize": 1e100}), + ] { + let action = Action::new("Unsupported Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); + assert!(atn_cmds::export("Crystallize", &[&action], &[], false).is_err(), "{params}"); + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], true).unwrap(); + assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Unsupported Crystallize".into(), step: 0, id: CRYSTALLIZE.into() }]); + assert!(atn_cmds::import(&bytes).unwrap().actions[0].steps.is_empty()); + } +} diff --git a/crates/engine/tests/atn_ripple_amount.rs b/crates/engine/tests/it/atn_ripple_amount.rs similarity index 100% rename from crates/engine/tests/atn_ripple_amount.rs rename to crates/engine/tests/it/atn_ripple_amount.rs diff --git a/crates/engine/tests/it/atn_selective_color.rs b/crates/engine/tests/it/atn_selective_color.rs new file mode 100644 index 0000000000..454846f59e --- /dev/null +++ b/crates/engine/tests/it/atn_selective_color.rs @@ -0,0 +1,147 @@ +//! #7672: strict ATN export must preserve both accepted Selective Color parameter forms. +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{Session, atn_cmds}; +use serde_json::{Value, json}; + +const RANGES: [&str; 9] = ["reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks"]; +const COMMANDS: [&str; 2] = ["image.adjustments.selectiveColor", "layer.newAdjustmentLayer.selectiveColor"]; + +fn fixture(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 10, "height": 2, "depth": depth})).unwrap(); + let palette = [ + [0.85, 0.10, 0.05, 1.0], + [0.85, 0.80, 0.05, 1.0], + [0.05, 0.85, 0.10, 1.0], + [0.05, 0.80, 0.85, 1.0], + [0.10, 0.05, 0.85, 1.0], + [0.85, 0.05, 0.80, 1.0], + [0.90, 0.93, 0.95, 1.0], + [0.45, 0.50, 0.55, 1.0], + [0.05, 0.08, 0.10, 1.0], + [0.65, 0.20, 0.70, 0.4], + ]; + s.edit("synthetic colours", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let bytes: Vec<_> = palette.iter().cycle().take(20).flat_map(|rgba| photocraft_raster::from_rgba(&format, *rgba)).collect(); + surface.write_region(doc_bounds(), &bytes); + Ok(()) + }) + .unwrap(); + s +} + +fn doc_bounds() -> photocraft_doc::Rect { + photocraft_doc::Rect::from_xywh(0, 0, 10, 2) +} + +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +fn check_cases(id: &str, cases: &[Value], require_effect: bool) { + let mut failures = Vec::new(); + for depth in [8, 16, 32] { + for params in cases { + let action = Action::new("Selective Color", vec![(id.into(), params.clone())]); + let mut direct = fixture(depth); + let before = pixels(&direct); + direct.execute(id, params.clone()).unwrap(); + let expected = pixels(&direct); + if require_effect { + assert_ne!(expected, before, "ineffective fixture: {id}, depth {depth}, {params}"); + } + + let mut native = fixture(depth); + native.actions.list.push(action.clone()); + native.execute("actions.play", json!({"action": action.name})).unwrap(); + assert_eq!(pixels(&native), expected, "native replay: {id}, depth {depth}, {params}"); + + let (bytes, skipped) = atn_cmds::export("Regression", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert!(imported.warnings.is_empty()); + assert_eq!(imported.actions.len(), 1); + assert_eq!(imported.actions[0].steps.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, id); + + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action": action.name})).unwrap(); + if pixels(&replay) != expected { + failures.push(format!("{id}, depth {depth}, {params}")); + } + } + } + assert!(failures.is_empty(), "ATN playback changed the result:\n{}", failures.join("\n")); +} + +fn scalar_cases() -> Vec { + RANGES + .iter() + .flat_map(|range| { + ["relative", "absolute"].map(|method| json!({"method": method, "colors": range, "cyan": 35, "magenta": -12.5, "yellow": 17.75, "black": 9})) + }) + .collect() +} + +#[test] +fn scalar_selective_color_actions_preserve_pixels_at_every_depth() { + let mut cases = scalar_cases(); + cases.push(json!({"method": "absolute", "colors": "reds", "cyan": 35})); + check_cases(COMMANDS[0], &cases, true); +} + +#[test] +fn scalar_selective_color_adjustment_layers_preserve_pixels_at_every_depth() { + check_cases(COMMANDS[1], &scalar_cases(), true); +} + +#[test] +fn mixed_forms_method_precedence_nulls_and_clamping_keep_their_effect() { + let cases = [ + json!({"method": "absolute", "reds": [10, 20, 30, 40], "blues": [-10, 5, 0, 4], "colors": "reds", "cyan": 0, "yellow": -17.5}), + json!({"method": "relative", "relative": false, "colors": "reds", "cyan": 35}), + json!({"method": "absolute", "relative": true, "colors": "neutrals", "black": 15}), + json!({"colors": "blues", "magenta": -25}), + json!({"method": null, "relative": null, "reds": null, "colors": "reds", "cyan": 35, "black": null}), + json!({"method": "absolute", "colors": "reds", "cyan": 175, "magenta": -150}), + json!({"method": "absolute", "reds": [175, -150, 0, 0]}), + json!({"method": "absolute", "reds": [10, 20, 30, 40], "colors": "reds", "cyan": null}), + json!({}), + json!({"colors": "reds"}), + ]; + for id in COMMANDS { + check_cases(id, &cases, false); + } +} + +#[test] +fn per_range_array_controls_still_preserve_pixels() { + let cases: Vec<_> = RANGES.iter().map(|range| json!({"method": "absolute", *range: [35, -12.5, 17.75, 9]})).collect(); + for id in COMMANDS { + check_cases(id, &cases, true); + } +} + +#[test] +fn malformed_selective_color_params_cannot_be_silently_exported() { + for params in [ + json!({"colors": "purples", "cyan": 35}), + json!({"colors": 3}), + json!({"colors": "reds", "cyan": "35"}), + json!({"method": "sideways"}), + json!({"relative": "yes"}), + json!({"reds": [10, 20]}), + json!({"reds": [10, "20", 0, 0]}), + ] { + for id in COMMANDS { + let action = Action::new("Invalid", vec![(id.into(), params.clone())]); + assert!(atn_cmds::export("Regression", &[&action], &[], false).is_err(), "strict export accepted {id} {params}"); + let (_, skipped) = atn_cmds::export("Regression", &[&action], &[], true).unwrap(); + assert_eq!(skipped.len(), 1, "skipUnsupported must report {id} {params}"); + } + } +} diff --git a/crates/engine/tests/atn_stroke_width.rs b/crates/engine/tests/it/atn_stroke_width.rs similarity index 100% rename from crates/engine/tests/atn_stroke_width.rs rename to crates/engine/tests/it/atn_stroke_width.rs diff --git a/crates/engine/tests/it/background_and_mask_depth.rs b/crates/engine/tests/it/background_and_mask_depth.rs new file mode 100644 index 0000000000..1fa400e244 --- /dev/null +++ b/crates/engine/tests/it/background_and_mask_depth.rs @@ -0,0 +1,102 @@ +use photocraft_color::SampleType; +use photocraft_engine::Session; +use serde_json::{Value, json}; + +const STYLES: [&str; 10] = + ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "patternOverlay", "bevelEmboss", "satin"]; +const VECTOR_MASKS: [&str; 3] = ["layer.vectorMask.add", "layer.vectorMask.revealAll", "layer.vectorMask.hideAll"]; + +fn background_only() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s +} + +fn has_background(s: &Session) -> bool { + s.active().unwrap().doc.background().is_some() +} + +fn converts_and_undoes(cmd: &str, params: Value) { + let mut s = background_only(); + s.execute(cmd, params).unwrap_or_else(|e| panic!("{cmd}: {e}")); + assert!(!has_background(&s), "{cmd} left a Background carrying the change"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(has_background(&s), "{cmd}: one undo restores the Background"); +} + +#[test] +fn layer_styles_and_vector_masks_turn_the_background_into_a_layer() { + for kind in STYLES { + converts_and_undoes(&format!("layer.layerStyle.{kind}"), json!({})); + } + for cmd in VECTOR_MASKS { + converts_and_undoes(cmd, json!({})); + } + converts_and_undoes("layer.layerStyle.replace", json!({"effects": [{"kind": "stroke"}]})); +} + +#[test] +fn clearing_a_style_keeps_the_background() { + let mut s = background_only(); + s.execute("layer.layerStyle.replace", json!({"effects": []})).unwrap(); + assert!(has_background(&s)); +} + +fn mask_sample(s: &Session) -> SampleType { + let st = s.active().unwrap(); + st.doc.walk().into_iter().find_map(|(_, _, l)| l.mask.as_ref().map(|m| m.surface.format().sample)).expect("a layer mask") +} + +fn painted(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [30, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s +} + +#[test] +fn frame_and_remove_background_masks_match_the_document_depth() { + for depth in [16, 32] { + let want = if depth == 16 { SampleType::U16 } else { SampleType::F32 }; + for (cmd, params) in [ + ("layer.new.frameFromLayers", json!({})), + ("layer.new.frame", json!({"x": 4, "y": 4, "width": 16, "height": 12})), + ("layer.new.frame", json!({"x": 4, "y": 4, "width": 16, "height": 12, "shape": "ellipse"})), + ("layer.removeBackground", json!({})), + ] { + let mut s = painted(depth); + s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}")); + assert_eq!(mask_sample(&s), want, "{cmd} {params} in a {depth}-bit document"); + } + } +} + +fn copied_style_over_background() -> (Session, u64, u64) { + let mut s = background_only(); + let bg = s.active().unwrap().doc.background().unwrap().id.0; + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap().0; + (s, bg, layer) +} + +#[test] +fn pasting_a_style_onto_the_background_turns_it_into_a_layer() { + let (mut s, bg, _) = copied_style_over_background(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": bg})).unwrap(); + assert!(!has_background(&s), "the pasted style landed on a Background"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(has_background(&s), "one undo restores the Background"); +} + +#[test] +fn pasting_a_style_with_no_layer_skips_the_background() { + let (mut s, bg, layer) = copied_style_over_background(); + s.execute("layer.select", json!({"layer": bg})).unwrap(); + s.execute("layer.select", json!({"layer": layer, "mode": "add"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap(); + assert!(has_background(&s)); + assert!(s.active().unwrap().doc.background().unwrap().effects.items.is_empty()); +} diff --git a/crates/engine/tests/background_layer_consistency.rs b/crates/engine/tests/it/background_layer_consistency.rs similarity index 100% rename from crates/engine/tests/background_layer_consistency.rs rename to crates/engine/tests/it/background_layer_consistency.rs diff --git a/crates/engine/tests/brush_smoothing.rs b/crates/engine/tests/it/brush_smoothing.rs similarity index 100% rename from crates/engine/tests/brush_smoothing.rs rename to crates/engine/tests/it/brush_smoothing.rs diff --git a/crates/engine/tests/camera_raw_exposure_clamp.rs b/crates/engine/tests/it/camera_raw_exposure_clamp.rs similarity index 100% rename from crates/engine/tests/camera_raw_exposure_clamp.rs rename to crates/engine/tests/it/camera_raw_exposure_clamp.rs diff --git a/crates/engine/tests/camera_raw_noise_clamp.rs b/crates/engine/tests/it/camera_raw_noise_clamp.rs similarity index 100% rename from crates/engine/tests/camera_raw_noise_clamp.rs rename to crates/engine/tests/it/camera_raw_noise_clamp.rs diff --git a/crates/engine/tests/clipping_semantics.rs b/crates/engine/tests/it/clipping_semantics.rs similarity index 100% rename from crates/engine/tests/clipping_semantics.rs rename to crates/engine/tests/it/clipping_semantics.rs diff --git a/crates/engine/tests/close_document.rs b/crates/engine/tests/it/close_document.rs similarity index 100% rename from crates/engine/tests/close_document.rs rename to crates/engine/tests/it/close_document.rs diff --git a/crates/engine/tests/command_lookup.rs b/crates/engine/tests/it/command_lookup.rs similarity index 100% rename from crates/engine/tests/command_lookup.rs rename to crates/engine/tests/it/command_lookup.rs diff --git a/crates/engine/tests/it/convert_keeps_references.rs b/crates/engine/tests/it/convert_keeps_references.rs new file mode 100644 index 0000000000..a9859d912e --- /dev/null +++ b/crates/engine/tests/it/convert_keeps_references.rs @@ -0,0 +1,47 @@ +use photocraft_doc::{LayerId, SliceOrigin}; +use photocraft_engine::Session; +use serde_json::json; + +fn art() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Art"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("layer.newLayerBasedSlice", json!({})).unwrap(); + s.execute("image.variables.define", json!({"defs": [{"name": "v", "layer": id.0, "type": "visibility"}]})).unwrap(); + (s, id) +} + +fn references_point_at(s: &Session, id: LayerId) -> bool { + let doc = &s.active().unwrap().doc; + let slice = doc.slices.list.iter().any(|sl| sl.origin == SliceOrigin::Layer && sl.layer == Some(id)); + let comps = doc.layer_comps.iter().all(|c| c.states.iter().any(|st| st.layer == id)); + let vars = doc.variables.defs.iter().all(|d| d.layer == id); + slice && comps && vars +} + +#[test] +fn converting_a_layer_keeps_its_slice_comps_and_variables() { + for cmd in ["layer.smartObjects.convertToSmartObject", "filter.convertForSmartFilters"] { + let (mut s, id) = art(); + s.execute("layerComp.new", json!({"name": "A", "visibility": true, "position": true, "appearance": true})).unwrap(); + s.execute(cmd, json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + assert!(references_point_at(&s, smart), "{cmd} left references on the old layer"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(references_point_at(&s, id), "{cmd}: undo restores the original references"); + } +} + +#[test] +fn a_comp_does_not_put_the_converted_layers_style_on_the_smart_object() { + let (mut s, _) = art(); + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + let r = s.execute("layerComp.new", json!({"name": "A", "visibility": true, "position": true, "appearance": true})).unwrap(); + let comp = r["comp"].as_u64().unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + s.execute("layerComp.apply", json!({"comp": comp})).unwrap(); + assert!(s.active().unwrap().doc.layer(smart).unwrap().effects.items.is_empty(), "the style stays inside the smart object"); +} diff --git a/crates/engine/tests/it/copy_shape_placement.rs b/crates/engine/tests/it/copy_shape_placement.rs new file mode 100644 index 0000000000..ee6d016984 --- /dev/null +++ b/crates/engine/tests/it/copy_shape_placement.rs @@ -0,0 +1,74 @@ +//! Copy placement must use source-space bounds before destination cache clipping. +use photocraft_doc::LayerContent; +use photocraft_engine::Session; +use serde_json::json; + +/// Cover source clipping, groups, missing caches, destination depth, placement and Undo/Redo. +#[test] +fn copying_shapes_to_smaller_documents_preserves_requested_center() { + for x in [90, 700] { + for grouped in [false, true] { + for missing_cache in [false, true] { + for depth in [8, 16] { + for at in [None, Some([40, 60])] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":800,"height":600,"background":"transparent"})).unwrap(); + let mut ids = Vec::new(); + for offset in if grouped { vec![0, 30] } else { vec![0] } { + s.execute("shape.create", json!({"kind":"rect","rect":[x+offset,20,20,20],"fill":"#ff0000"})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap(); + ids.push(s.active().unwrap().active_layer.unwrap()); + } + if grouped { + s.active_mut().unwrap().selected_layers = ids.clone(); + s.execute("layer.groupLayers", json!({})).unwrap(); + } + if missing_cache { + s.edit("drop cached shapes", |doc, _| { + for id in &ids { + let LayerContent::Shape(shape) = &mut doc.layer_mut(*id).unwrap().content else { panic!("shape") }; + shape.cache = None; + } + Ok(()) + }) + .unwrap(); + } + s.execute("file.new", json!({"width":100,"height":100,"depth":depth,"background":"transparent"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let mut params = json!({"source":0,"document":1,"center":true}); + if let Some(at) = at { + params["center"] = json!(false); + params["at"] = json!(at); + } + let result = s.execute("layer.copyToDocument", params).unwrap(); + let target = at.unwrap_or([50, 50]); + let dx = target[0] - (x + if grouped { 25 } else { 10 }); + let dy = target[1] - 30; + assert_eq!(result["offset"], json!([dx, dy]), "x={x}, grouped={grouped}, cache missing={missing_cache}"); + let st = s.active().unwrap(); + let shapes: Vec<_> = st + .doc + .walk() + .into_iter() + .filter_map(|(_, _, layer)| if let LayerContent::Shape(shape) = &layer.content { Some(shape) } else { None }) + .collect(); + assert_eq!(shapes.len(), if grouped { 2 } else { 1 }); + for shape in shapes { + let fresh = photocraft_vector::render_shape(shape, st.doc.pixel_format(), st.doc.bounds()); + assert!( + shape.cache.as_ref() == Some(&fresh), + "cache mismatch: x={x}, grouped={grouped}, cache missing={missing_cache}, depth={depth}, at={at:?}" + ); + assert!(!fresh.content_bounds().is_empty()); + } + let after = st.doc.clone(); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + } + } + } + } + } +} diff --git a/crates/engine/tests/corpus_atn.rs b/crates/engine/tests/it/corpus_atn.rs similarity index 72% rename from crates/engine/tests/corpus_atn.rs rename to crates/engine/tests/it/corpus_atn.rs index 77f9fd0c4a..afc11cbc2a 100644 --- a/crates/engine/tests/corpus_atn.rs +++ b/crates/engine/tests/it/corpus_atn.rs @@ -7,7 +7,8 @@ use std::path::{Path, PathBuf}; use photocraft_engine::Session; -use serde_json::json; +use photocraft_engine::actions_cmds::Action; +use serde_json::{Value, json}; fn collect(dir: &Path, out: &mut Vec) { // An unreadable folder fails the test rather than shrinking the corpus. @@ -30,6 +31,47 @@ 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); + } + } + out + }) + .collect() +} + #[test] fn atn_corpus_loads_saves_and_reloads() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/atn"); @@ -68,7 +110,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 t.actions.list == first => {} + Ok(_) if merge_set_props(&t.actions.list) == merge_set_props(&first) => {} Ok(_) => { // Name the first step that differs, so a mapping asymmetry is quick to find. let detail = first diff --git a/crates/engine/tests/crop_content_aware.rs b/crates/engine/tests/it/crop_content_aware.rs similarity index 71% rename from crates/engine/tests/crop_content_aware.rs rename to crates/engine/tests/it/crop_content_aware.rs index f41d46a9b5..0cc3dbdeca 100644 --- a/crates/engine/tests/crop_content_aware.rs +++ b/crates/engine/tests/it/crop_content_aware.rs @@ -157,3 +157,62 @@ fn bad_params_and_unfillable_crops_fail_cleanly() { assert_eq!(s.active().unwrap().history.past_len(), past, "{p}"); } } + +/// `doc` with its left 16 columns red, then cropped to x = 16 keeping the cropped pixels: the red +/// lies hidden past the canvas's left edge. +fn hidden_red(background: &str) -> Session { + let mut s = doc("rgb", 8, background); + s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"contents": "foreground"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.crop", json!({"x": 16, "y": 0, "width": 48, "height": 48, "deleteCroppedPixels": false})).unwrap(); + assert_eq!(size(&s), (48, 48)); + s +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// A straight frame pulled back out over pixels a Delete Cropped Pixels off crop hid fills over +/// them from the canvas, as Photoshop does with Delete Cropped Pixels off or on (#8060). +#[test] +fn extending_crop_fills_over_the_hidden_pixels() { + for background in ["transparent", "white"] { + for delete in [false, true] { + let mut s = hidden_red(background); + let green = layer(&s).rgba(24, 24); + crop(&mut s, json!({"x": -24, "y": 0, "width": 72, "height": 48, "deleteCroppedPixels": delete}), true).unwrap(); + assert_eq!(size(&s), (72, 48)); + let surf = layer(&s); + for y in 0..48 { + for x in 0..24 { + let px = surf.rgba(x, y); + assert!(close(px, green, 0.06), "{background} delete {delete}: ({x}, {y}) is filled from the canvas, not the hidden red: {px:?}"); + } + assert!(close(surf.rgba(40, y), green, 1e-6), "{background} delete {delete}: the canvas is unchanged"); + } + } + } +} + +/// A turned frame that reaches over the Background's hidden pixels shows them, not a fill +/// from the canvas (#8060). +#[test] +fn rotated_crop_keeps_the_hidden_pixels() { + let mut s = hidden_red("white"); + // A diamond on the canvas's left edge: its left half lies over the hidden red, all of it inside + // the turned canvas (so nothing takes the background colour). + crop(&mut s, json!({"x": -10, "y": 14, "width": 20, "height": 20, "angle": 45, "deleteCroppedPixels": false}), true).unwrap(); + assert_eq!(size(&s), (20, 20)); + let (w, h) = size(&s); + let surf = layer(&s); + let mut red = 0; + for y in 0..h as i32 { + for x in 0..w as i32 { + let px = surf.rgba(x, y); + assert!(px[0] > 0.5 && px[3] > 0.99, "no background colour or transparency at ({x}, {y}): {px:?}"); + red += usize::from(close(px, RED, 0.06)); + } + } + assert!(red >= 100, "the hidden red shows in the turned frame: {red} red pixels"); +} diff --git a/crates/engine/tests/current_affinity_artboards.rs b/crates/engine/tests/it/current_affinity_artboards.rs similarity index 97% rename from crates/engine/tests/current_affinity_artboards.rs rename to crates/engine/tests/it/current_affinity_artboards.rs index e3b27e856b..8348b4065f 100644 --- a/crates/engine/tests/current_affinity_artboards.rs +++ b/crates/engine/tests/it/current_affinity_artboards.rs @@ -1,7 +1,7 @@ //! Artboards recognized from current .af properties move with their children and undo together. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -#[path = "../../io/tests/support/current_affinity.rs"] +#[path = "../../../io/tests/support/current_affinity.rs"] mod fixture; use fixture::{Marker, document}; diff --git a/crates/engine/tests/duplicate_layer_comps.rs b/crates/engine/tests/it/duplicate_layer_comps.rs similarity index 100% rename from crates/engine/tests/duplicate_layer_comps.rs rename to crates/engine/tests/it/duplicate_layer_comps.rs diff --git a/crates/engine/tests/duplicate_slices.rs b/crates/engine/tests/it/duplicate_slices.rs similarity index 100% rename from crates/engine/tests/duplicate_slices.rs rename to crates/engine/tests/it/duplicate_slices.rs diff --git a/crates/engine/tests/duplicate_variables.rs b/crates/engine/tests/it/duplicate_variables.rs similarity index 100% rename from crates/engine/tests/duplicate_variables.rs rename to crates/engine/tests/it/duplicate_variables.rs diff --git a/crates/engine/tests/empty_selection_combine.rs b/crates/engine/tests/it/empty_selection_combine.rs similarity index 100% rename from crates/engine/tests/empty_selection_combine.rs rename to crates/engine/tests/it/empty_selection_combine.rs diff --git a/crates/engine/tests/eraser_locked.rs b/crates/engine/tests/it/eraser_locked.rs similarity index 100% rename from crates/engine/tests/eraser_locked.rs rename to crates/engine/tests/it/eraser_locked.rs diff --git a/crates/engine/tests/explicit_layer_target.rs b/crates/engine/tests/it/explicit_layer_target.rs similarity index 100% rename from crates/engine/tests/explicit_layer_target.rs rename to crates/engine/tests/it/explicit_layer_target.rs diff --git a/crates/engine/tests/it/export_shape_strokes.rs b/crates/engine/tests/it/export_shape_strokes.rs new file mode 100644 index 0000000000..0875f4bfc0 --- /dev/null +++ b/crates/engine/tests/it/export_shape_strokes.rs @@ -0,0 +1,105 @@ +//! Regression coverage for #8341: canvas resizing must scale shape strokes too. +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{ + Session, + web_cmds::{WebSettings, web_document}, +}; +use serde_json::json; + +fn shape_session(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.execute("shape.create", json!({"rect": [256, 256, 512, 512], "fill": null, "stroke": {"width": 32, "color": "#000000"}})).unwrap()["layer"] + .as_u64() + .unwrap(); + (s, LayerId(id)) +} + +#[test] +fn web_downscale_keeps_stroke_only_shape_proportions_at_every_depth() { + for depth in [8, 16, 32] { + let (s, id) = shape_session(depth); + let original = &s.active().unwrap().doc; + for params in [json!({"percent": 25}), json!({"width": 256, "height": 256})] { + let (scaled, sx, sy) = web_document(original, ¶ms, &WebSettings::default()).unwrap(); + assert_eq!((scaled.size.width, scaled.size.height), (256, 256)); + assert_eq!((sx, sy), (0.25, 0.25)); + let LayerContent::Shape(sh) = &scaled.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 8.0, "{depth}-bit stroke"); + let pixels = photocraft_compose::flatten(&scaled); + let alpha = |x: usize| pixels.px[128 * 256 + x][3]; + assert!(alpha(62) > 0.99, "scaled stroke is visible"); + assert!(alpha(52) < 0.01, "stroke must not retain its original width"); + assert!(alpha(128) < 0.01, "no fill is introduced"); + } + let LayerContent::Shape(sh) = &original.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 32.0, "export leaves the source untouched"); + } +} + +#[test] +fn image_size_scales_strokes_once_and_undo_restores_them() { + let (mut s, id) = shape_session(8); + s.execute("shape.edit", json!({"layer": id.0, "stroke": {"dashes": [2, 1], "dashOffset": 0.5}})).unwrap(); + let before = s.active().unwrap().doc.layer(id).unwrap().clone(); + s.execute("image.imageSize", json!({"width": 512, "height": 512})).unwrap(); + let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 16.0); + assert_eq!(sh.stroke.as_ref().unwrap().dashes, vec![2.0, 1.0], "dash lengths stay in stroke-width units"); + assert_eq!(sh.stroke.as_ref().unwrap().dash_offset, 0.5); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap(), &before); + s.execute("edit.redo", json!({})).unwrap(); + let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 16.0); +} + +#[test] +fn grouped_strokes_scale_up_and_unchanged_size_is_a_noop() { + let (mut s, id) = shape_session(8); + s.edit("Group", |doc, _| { + let children = std::mem::take(&mut doc.layers); + doc.layers.push(photocraft_doc::Layer::group("Outer", vec![photocraft_doc::Layer::group("Inner", children)])); + Ok(()) + }) + .unwrap(); + let original = &s.active().unwrap().doc; + for (width, expected) in [(1024, 32.0), (1280, 40.0), (256, 8.0)] { + let (scaled, _, _) = web_document(original, &json!({"width": width}), &WebSettings::default()).unwrap(); + let LayerContent::Shape(sh) = &scaled.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, expected); + } +} + +#[test] +fn png_export_contains_the_scaled_stroke_and_keeps_the_source_document() { + let (mut s, _) = shape_session(8); + let before = s.active().unwrap().doc.clone(); + let mut bytes = Vec::new(); + photocraft_engine::web_cmds::save_for_web_with_writer( + &mut s, + &json!({"format": "png24", "path": "stroke.png", "width": 256, "height": 256}), + &mut |_, encoded| { + bytes = encoded.to_vec(); + Ok(()) + }, + ) + .unwrap(); + let image = photocraft_codecs::decode(&bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + assert_eq!(image.dimensions(), (256, 256)); + let alpha = |x: usize| image.data()[(128 * 256 + x) * 4 + 3]; + assert_eq!(alpha(62), 255); + assert_eq!(alpha(52), 0); + assert_eq!(alpha(128), 0); + assert_eq!(s.active().unwrap().doc, before); +} diff --git a/crates/engine/tests/fill_lock_transparency.rs b/crates/engine/tests/it/fill_lock_transparency.rs similarity index 100% rename from crates/engine/tests/fill_lock_transparency.rs rename to crates/engine/tests/it/fill_lock_transparency.rs diff --git a/crates/engine/tests/filter_locked.rs b/crates/engine/tests/it/filter_locked.rs similarity index 100% rename from crates/engine/tests/filter_locked.rs rename to crates/engine/tests/it/filter_locked.rs diff --git a/crates/engine/tests/finite_command_params.rs b/crates/engine/tests/it/finite_command_params.rs similarity index 100% rename from crates/engine/tests/finite_command_params.rs rename to crates/engine/tests/it/finite_command_params.rs diff --git a/crates/engine/tests/flatten_background.rs b/crates/engine/tests/it/flatten_background.rs similarity index 100% rename from crates/engine/tests/flatten_background.rs rename to crates/engine/tests/it/flatten_background.rs diff --git a/crates/engine/tests/gallery_colours.rs b/crates/engine/tests/it/gallery_colours.rs similarity index 100% rename from crates/engine/tests/gallery_colours.rs rename to crates/engine/tests/it/gallery_colours.rs diff --git a/crates/engine/tests/gauss_small_sigma.rs b/crates/engine/tests/it/gauss_small_sigma.rs similarity index 100% rename from crates/engine/tests/gauss_small_sigma.rs rename to crates/engine/tests/it/gauss_small_sigma.rs diff --git a/crates/engine/tests/gradient_overlay_strokes.rs b/crates/engine/tests/it/gradient_overlay_strokes.rs similarity index 100% rename from crates/engine/tests/gradient_overlay_strokes.rs rename to crates/engine/tests/it/gradient_overlay_strokes.rs diff --git a/crates/engine/tests/guide_layout_limit.rs b/crates/engine/tests/it/guide_layout_limit.rs similarity index 100% rename from crates/engine/tests/guide_layout_limit.rs rename to crates/engine/tests/it/guide_layout_limit.rs diff --git a/crates/engine/tests/guide_layout_preview.rs b/crates/engine/tests/it/guide_layout_preview.rs similarity index 100% rename from crates/engine/tests/guide_layout_preview.rs rename to crates/engine/tests/it/guide_layout_preview.rs diff --git a/crates/engine/tests/hdr_gamma_direction.rs b/crates/engine/tests/it/hdr_gamma_direction.rs similarity index 100% rename from crates/engine/tests/hdr_gamma_direction.rs rename to crates/engine/tests/it/hdr_gamma_direction.rs diff --git a/crates/engine/tests/huge_doc_flatten.rs b/crates/engine/tests/it/huge_doc_flatten.rs similarity index 100% rename from crates/engine/tests/huge_doc_flatten.rs rename to crates/engine/tests/it/huge_doc_flatten.rs diff --git a/crates/engine/tests/jpeg_exif_resolution.rs b/crates/engine/tests/it/jpeg_exif_resolution.rs similarity index 100% rename from crates/engine/tests/jpeg_exif_resolution.rs rename to crates/engine/tests/it/jpeg_exif_resolution.rs diff --git a/crates/engine/tests/keyboard_shortcut_bulk.rs b/crates/engine/tests/it/keyboard_shortcut_bulk.rs similarity index 100% rename from crates/engine/tests/keyboard_shortcut_bulk.rs rename to crates/engine/tests/it/keyboard_shortcut_bulk.rs diff --git a/crates/engine/tests/kra.rs b/crates/engine/tests/it/kra.rs similarity index 96% rename from crates/engine/tests/kra.rs rename to crates/engine/tests/it/kra.rs index 8bdf10531c..783c5fb28f 100644 --- a/crates/engine/tests/kra.rs +++ b/crates/engine/tests/it/kra.rs @@ -1,7 +1,7 @@ //! Krita documents opened through the session: read-only source, 16-bit layers, editable //! layers with history, and saving as OpenRaster or native. The files come from //! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus. Run with -//! `KRITA_TEST_DIR= cargo test -p photocraft-engine --test kra -- --ignored --nocapture`. +//! `KRITA_TEST_DIR= cargo test -p photocraft-engine --test it -- kra:: --ignored --nocapture`. use photocraft_engine::Session; use serde_json::json; @@ -24,7 +24,7 @@ fn krita_documents_open_read_only_and_stay_editable() { assert!(session.execute("file.save", json!({})).is_err()); // Krita's merged image is the composite (the corpus files carry no ICC profile, so both - // sides are sRGB); crates/io/tests/krita_corpus.rs compares every file. + // sides are sRGB); crates/io/tests/it/krita_corpus.rs compares every file. let ours = photocraft_compose::flatten(&session.active().unwrap().doc); let zip_bytes = std::fs::read(&path).unwrap(); let zip = photocraft_format::zip::ZipReader::new(&zip_bytes).unwrap(); diff --git a/crates/engine/tests/kys_import.rs b/crates/engine/tests/it/kys_import.rs similarity index 100% rename from crates/engine/tests/kys_import.rs rename to crates/engine/tests/it/kys_import.rs diff --git a/crates/engine/tests/layer_delete_last.rs b/crates/engine/tests/it/layer_delete_last.rs similarity index 100% rename from crates/engine/tests/layer_delete_last.rs rename to crates/engine/tests/it/layer_delete_last.rs diff --git a/crates/engine/tests/layer_mask_depth.rs b/crates/engine/tests/it/layer_mask_depth.rs similarity index 100% rename from crates/engine/tests/layer_mask_depth.rs rename to crates/engine/tests/it/layer_mask_depth.rs diff --git a/crates/engine/tests/layer_props_several.rs b/crates/engine/tests/it/layer_props_several.rs similarity index 100% rename from crates/engine/tests/layer_props_several.rs rename to crates/engine/tests/it/layer_props_several.rs diff --git a/crates/engine/tests/layer_range_panel_order.rs b/crates/engine/tests/it/layer_range_panel_order.rs similarity index 100% rename from crates/engine/tests/layer_range_panel_order.rs rename to crates/engine/tests/it/layer_range_panel_order.rs diff --git a/crates/engine/tests/live_line_transform.rs b/crates/engine/tests/it/live_line_transform.rs similarity index 100% rename from crates/engine/tests/live_line_transform.rs rename to crates/engine/tests/it/live_line_transform.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 0000000000..25455e539c --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,83 @@ +//! The crate's integration tests, linked into one test binary. + +mod affinity; +mod artboard_export_names; +mod atn_crystallize_cell_size; +mod atn_ripple_amount; +mod atn_selective_color; +mod atn_stroke_width; +mod background_and_mask_depth; +mod background_layer_consistency; +mod brush_smoothing; +mod camera_raw_exposure_clamp; +mod camera_raw_noise_clamp; +mod clipping_semantics; +mod close_document; +mod command_lookup; +mod convert_keeps_references; +mod copy_shape_placement; +mod corpus_atn; +mod crop_content_aware; +mod current_affinity_artboards; +mod duplicate_layer_comps; +mod duplicate_slices; +mod duplicate_variables; +mod empty_selection_combine; +mod eraser_locked; +mod explicit_layer_target; +mod export_shape_strokes; +mod fill_lock_transparency; +mod filter_locked; +mod finite_command_params; +mod flatten_background; +mod gallery_colours; +mod gauss_small_sigma; +mod gradient_overlay_strokes; +mod guide_layout_limit; +mod guide_layout_preview; +mod hdr_gamma_direction; +mod huge_doc_flatten; +mod jpeg_exif_resolution; +mod keyboard_shortcut_bulk; +mod kra; +mod kys_import; +mod layer_delete_last; +mod layer_mask_depth; +mod layer_props_several; +mod layer_range_panel_order; +mod live_line_transform; +mod mask_lock_all; +mod merge_transparent_bounds; +mod missing_layer_error; +mod nonfinite_style; +mod open_identity; +mod path_blur_speed_clamp; +mod path_transform_target; +mod pdn; +mod photoshop_oracles; +mod pixel_lock_edits; +mod precheck; +mod prefs_usage; +mod rasterize_psd_reopen; +mod rasterize_selected_layers; +mod rendering_preferences; +mod save_keeps_encoding; +mod selection_layer_commands; +mod shortcut_sets; +mod shortcuts_headless; +mod smart_clipboard_sources; +mod smart_duplicates; +mod smart_filter_canvas_geometry; +mod smart_object_tones; +mod smart_psd; +mod style_and_align; +mod svg_smart_object; +mod transform_budget_unclamped; +mod type_format_targets; +mod type_postscript_font; +mod undo_keeps_active_layer; +mod unicode_colors; +mod unsupported_smart_filters; +mod wand_transparent_copy; +mod working_rgb_profile; +mod xmp_charref; diff --git a/crates/engine/tests/it/mask_lock_all.rs b/crates/engine/tests/it/mask_lock_all.rs new file mode 100644 index 0000000000..74ea71ff62 --- /dev/null +++ b/crates/engine/tests/it/mask_lock_all.rs @@ -0,0 +1,92 @@ +use photocraft_doc::LayerMask; +use photocraft_engine::Session; +use serde_json::json; + +const COMMANDS: [&str; 7] = [ + "layer.layerMask.revealAll", + "layer.layerMask.hideAll", + "layer.layerMask.revealSelection", + "layer.layerMask.hideSelection", + "layer.layerMask.delete", + "layer.maskAllObjects", + "image.adjustments.invert", +]; + +fn masked(all: bool) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[25, 20], [35, 20]], "size": 6, "hardness": 1.0, "color": "#000000", "target": "mask"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 15})).unwrap(); + s.execute("layer.setProps", json!({"locks": {"all": all}})).unwrap(); + s +} + +fn mask(s: &Session) -> Option { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.clone() +} + +fn undo_depth(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +#[test] +fn lock_all_refuses_every_mask_command() { + for cmd in COMMANDS { + let mut s = masked(true); + let before = mask(&s); + let undo = undo_depth(&s); + let err = s.execute(cmd, json!({"target": "mask"})).unwrap_err(); + assert_eq!(err.to_string(), "Could not complete your request because the layer \"Layer 1\" is locked", "{cmd}"); + assert_eq!(mask(&s), before, "{cmd} left the mask unchanged"); + assert_eq!(undo_depth(&s), undo, "{cmd} added no history step"); + } +} + +#[test] +fn unlocked_layers_still_take_every_mask_command() { + for cmd in COMMANDS { + let mut s = masked(false); + let before = mask(&s); + s.execute(cmd, json!({"target": "mask"})).unwrap_or_else(|e| panic!("{cmd}: {e}")); + assert_ne!(mask(&s), before, "{cmd} changed the mask"); + } +} + +#[test] +fn a_locked_layer_among_the_selected_layers_refuses_the_whole_mask() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.setProps", json!({"locks": {"all": true}})).unwrap(); + s.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers.len(), 2); + assert!(s.execute("layer.layerMask.hideAll", json!({})).is_err()); + assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, l)| l.mask.is_none()), "no layer got a mask"); +} + +#[test] +fn paste_into_a_lock_all_group_refuses_before_pasting() { + for cmd in ["edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + let child = s.execute("layer.new.layer", json!({"name": "Child"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [20, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let group = s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": group, "locks": {"all": true}})).unwrap(); + s.execute("layer.select", json!({"layer": child, "mode": "replace"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 15})).unwrap(); + let layers = s.active().unwrap().doc.walk().len(); + let undo = undo_depth(&s); + assert!(s.execute(cmd, json!({})).is_err(), "{cmd}"); + assert_eq!(s.active().unwrap().doc.walk().len(), layers, "{cmd} pasted no layer"); + assert_eq!(undo_depth(&s), undo, "{cmd} added no history step"); + } +} diff --git a/crates/engine/tests/merge_transparent_bounds.rs b/crates/engine/tests/it/merge_transparent_bounds.rs similarity index 100% rename from crates/engine/tests/merge_transparent_bounds.rs rename to crates/engine/tests/it/merge_transparent_bounds.rs diff --git a/crates/engine/tests/missing_layer_error.rs b/crates/engine/tests/it/missing_layer_error.rs similarity index 100% rename from crates/engine/tests/missing_layer_error.rs rename to crates/engine/tests/it/missing_layer_error.rs diff --git a/crates/engine/tests/nonfinite_style.rs b/crates/engine/tests/it/nonfinite_style.rs similarity index 100% rename from crates/engine/tests/nonfinite_style.rs rename to crates/engine/tests/it/nonfinite_style.rs diff --git a/crates/engine/tests/it/open_identity.rs b/crates/engine/tests/it/open_identity.rs new file mode 100644 index 0000000000..2fa4ae17f6 --- /dev/null +++ b/crates/engine/tests/it/open_identity.rs @@ -0,0 +1,51 @@ +//! Background imports report the admitted document's identity, not just its temporary tab index. + +use std::sync::Arc; + +use photocraft_engine::Session; +use photocraft_engine::jobs::{OpenSource, Started}; +use serde_json::{Value, json}; + +fn source() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + session +} + +fn open(session: &mut Session, name: &str, bytes: Vec) -> Value { + match session.start_open(name, OpenSource::Bytes(Arc::new(bytes))).unwrap() { + Started::Done(result) => result, + Started::Job(job) => session.wait_job(job).unwrap(), + } +} + +#[test] +fn open_identity_reports_the_live_document_after_tabs_move() { + let input = source(); + let bytes = photocraft_io::export(&input.active().unwrap().doc, "synthetic.png", &Default::default()).unwrap().bytes; + let mut session = source(); + let result = open(&mut session, "synthetic.png", bytes); + let index = result["document"].as_u64().unwrap() as usize; + let imported = session.documents()[index].doc.id; + assert_eq!(result["docId"], imported.0, "the engine result must carry the actual admitted identity: {result}"); + session.move_document(index, 0).unwrap(); + assert_eq!(session.documents()[0].doc.id.0, result["docId"]); + assert_ne!(session.documents()[index].doc.id, imported, "the old index now identifies another document"); +} + +#[test] +fn open_identity_reports_the_remapped_id_when_a_native_document_is_opened_twice() { + let mut session = source(); + let original = session.active().unwrap().doc.clone(); + // A wholly synthetic in-memory native file retains its DocId. PNG and DNG allocate fresh + // IDs during decode, so they cannot deterministically exercise this collision. + let bytes = photocraft_format::PcraftWriter::new().save_zip(&original, &Default::default()).unwrap().0; + let result = open(&mut session, "synthetic.pcraft", bytes); + let index = result["document"].as_u64().unwrap() as usize; + let admitted = session.documents()[index].doc.id; + assert_ne!(admitted, original.id, "the original document still owns its persisted ID"); + assert_eq!(result["docId"], admitted.0, "report the remapped identity, not the ID decoded from the file"); + assert!(Arc::ptr_eq(&session.documents()[0].doc, &original)); +} diff --git a/crates/engine/tests/path_blur_speed_clamp.rs b/crates/engine/tests/it/path_blur_speed_clamp.rs similarity index 100% rename from crates/engine/tests/path_blur_speed_clamp.rs rename to crates/engine/tests/it/path_blur_speed_clamp.rs diff --git a/crates/engine/tests/path_transform_target.rs b/crates/engine/tests/it/path_transform_target.rs similarity index 100% rename from crates/engine/tests/path_transform_target.rs rename to crates/engine/tests/it/path_transform_target.rs diff --git a/crates/engine/tests/pdn.rs b/crates/engine/tests/it/pdn.rs similarity index 100% rename from crates/engine/tests/pdn.rs rename to crates/engine/tests/it/pdn.rs diff --git a/crates/engine/tests/photoshop_oracles.rs b/crates/engine/tests/it/photoshop_oracles.rs similarity index 98% rename from crates/engine/tests/photoshop_oracles.rs rename to crates/engine/tests/it/photoshop_oracles.rs index b1f0a133a1..88e63b67fc 100644 --- a/crates/engine/tests/photoshop_oracles.rs +++ b/crates/engine/tests/it/photoshop_oracles.rs @@ -3,7 +3,7 @@ //! `cargo xtask corpus --all`. Feature `corpus` (run with `cargo xtask test-corpus`); a missing //! corpus fails. //! -//! The io corpus test (`crates/io/tests/corpus.rs`) flattens imported documents, which composite +//! The io corpus test (`crates/io/tests/it/corpus.rs`) flattens imported documents, which composite //! Photoshop's own cached pixels for smart objects and type layers. This test throws those caches //! away: every smart object is re-rendered from its embedded source through our smart-filter //! stack, and every type layer is re-laid-out and re-rasterised by our text engine; the flatten is diff --git a/crates/engine/tests/pixel_lock_edits.rs b/crates/engine/tests/it/pixel_lock_edits.rs similarity index 100% rename from crates/engine/tests/pixel_lock_edits.rs rename to crates/engine/tests/it/pixel_lock_edits.rs diff --git a/crates/engine/tests/precheck.rs b/crates/engine/tests/it/precheck.rs similarity index 54% rename from crates/engine/tests/precheck.rs rename to crates/engine/tests/it/precheck.rs index f549fbb8c8..b737ddea5a 100644 --- a/crates/engine/tests/precheck.rs +++ b/crates/engine/tests/it/precheck.rs @@ -7,28 +7,32 @@ use serde_json::{Value, json}; /// Documents in different states: a Background only, a pixel layer active, a group active, a /// selection, no document. -fn states() -> Vec<(&'static str, Session)> { - let mut out = Vec::new(); - out.push(("no document", Session::new())); +const STATES: [&str; 5] = ["no document", "background only", "pixel layer", "group, no background", "selection"]; + +/// A fresh session in `state`. +fn state(state: &str) -> Session { let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); - out.push(("background only", s)); - let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); - s.execute("layer.new.layer", json!({})).unwrap(); - s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); - out.push(("pixel layer", s)); - let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); - s.execute("layer.new.group", json!({})).unwrap(); - out.push(("group, no background", s)); - let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); - s.execute("layer.new.layer", json!({})).unwrap(); - s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); - s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); - out.push(("selection", s)); - out + if state == "no document" { + return s; + } + let background = if state == "group, no background" { "transparent" } else { "white" }; + s.execute("file.new", json!({"width": 40, "height": 30, "background": background})).unwrap(); + match state { + "pixel layer" => { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + } + "group, no background" => { + s.execute("layer.new.group", json!({})).unwrap(); + } + "selection" => { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + } + _ => {} + } + s } fn params() -> Vec { @@ -63,13 +67,13 @@ fn precheck_agrees_with_execute() { let mut disagreements = Vec::new(); for id in PRECHECKED { for p in params() { - for (state, _) in states() { - // A fresh copy of the state for each call: execute changes it. - let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap(); + for name in STATES { + // A fresh session for each call: execute changes it. + let mut s = state(name); let pre = s.precheck(id, &p).is_ok(); let ran = s.execute(id, p.clone()).is_ok(); if pre != ran { - disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}")); + disagreements.push(format!("{id} {p} on {name}: precheck {pre}, execute {ran}")); } } } diff --git a/crates/engine/tests/prefs_usage.rs b/crates/engine/tests/it/prefs_usage.rs similarity index 100% rename from crates/engine/tests/prefs_usage.rs rename to crates/engine/tests/it/prefs_usage.rs diff --git a/crates/engine/tests/rasterize_psd_reopen.rs b/crates/engine/tests/it/rasterize_psd_reopen.rs similarity index 100% rename from crates/engine/tests/rasterize_psd_reopen.rs rename to crates/engine/tests/it/rasterize_psd_reopen.rs diff --git a/crates/engine/tests/rasterize_selected_layers.rs b/crates/engine/tests/it/rasterize_selected_layers.rs similarity index 100% rename from crates/engine/tests/rasterize_selected_layers.rs rename to crates/engine/tests/it/rasterize_selected_layers.rs diff --git a/crates/engine/tests/rendering_preferences.rs b/crates/engine/tests/it/rendering_preferences.rs similarity index 100% rename from crates/engine/tests/rendering_preferences.rs rename to crates/engine/tests/it/rendering_preferences.rs diff --git a/crates/engine/tests/save_keeps_encoding.rs b/crates/engine/tests/it/save_keeps_encoding.rs similarity index 100% rename from crates/engine/tests/save_keeps_encoding.rs rename to crates/engine/tests/it/save_keeps_encoding.rs diff --git a/crates/engine/tests/selection_layer_commands.rs b/crates/engine/tests/it/selection_layer_commands.rs similarity index 100% rename from crates/engine/tests/selection_layer_commands.rs rename to crates/engine/tests/it/selection_layer_commands.rs diff --git a/crates/engine/tests/shortcut_sets.rs b/crates/engine/tests/it/shortcut_sets.rs similarity index 100% rename from crates/engine/tests/shortcut_sets.rs rename to crates/engine/tests/it/shortcut_sets.rs diff --git a/crates/engine/tests/shortcuts_headless.rs b/crates/engine/tests/it/shortcuts_headless.rs similarity index 100% rename from crates/engine/tests/shortcuts_headless.rs rename to crates/engine/tests/it/shortcuts_headless.rs diff --git a/crates/engine/tests/smart_clipboard_sources.rs b/crates/engine/tests/it/smart_clipboard_sources.rs similarity index 100% rename from crates/engine/tests/smart_clipboard_sources.rs rename to crates/engine/tests/it/smart_clipboard_sources.rs diff --git a/crates/engine/tests/smart_duplicates.rs b/crates/engine/tests/it/smart_duplicates.rs similarity index 100% rename from crates/engine/tests/smart_duplicates.rs rename to crates/engine/tests/it/smart_duplicates.rs diff --git a/crates/engine/tests/smart_filter_canvas_geometry.rs b/crates/engine/tests/it/smart_filter_canvas_geometry.rs similarity index 100% rename from crates/engine/tests/smart_filter_canvas_geometry.rs rename to crates/engine/tests/it/smart_filter_canvas_geometry.rs diff --git a/crates/engine/tests/it/smart_object_tones.rs b/crates/engine/tests/it/smart_object_tones.rs new file mode 100644 index 0000000000..3c57526bad --- /dev/null +++ b/crates/engine/tests/it/smart_object_tones.rs @@ -0,0 +1,54 @@ +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, smart_cmds}; +use photocraft_raster::Surface; +use serde_json::json; + +fn mean_difference(a: &Surface, b: &Surface) -> f64 { + let r = a.content_bounds().intersect(&b.content_bounds()); + let (mut sum, mut n) = (0f64, 0u32); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (p, q) = (a.rgba(x, y), b.rgba(x, y)); + for c in 0..3 { + sum += f64::from((p[c] - q[c]).abs()); + n += 1; + } + } + } + sum / f64::from(n.max(1)) * 255.0 +} + +fn converted(cmd: &str) -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [60, 40]], "size": 20, "hardness": 0.3, "color": "#e04a20"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 40], [60, 5]], "size": 14, "hardness": 0.6, "color": "#2a7fd0"})).unwrap(); + let pixel = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + s.execute(cmd, json!({})).unwrap(); + (s, pixel, smart) +} + +#[test] +fn smart_objects_re_render_with_the_tones_of_the_converted_document() { + for cmd in ["image.mode.bits32", "image.mode.grayscale"] { + let (s, pixel, smart) = converted(cmd); + let st = s.active().unwrap(); + let layer = st.doc.layer(smart).unwrap().clone(); + let mut rerendered = layer.clone(); + smart_cmds::refresh_layer(&st.doc, &mut rerendered).unwrap(); + let (LayerContent::Smart(stored), LayerContent::Smart(fresh)) = (&layer.content, &rerendered.content) else { panic!("not a smart object") }; + let (stored, fresh) = (stored.cache.as_ref().unwrap(), fresh.cache.as_ref().unwrap()); + let reference = st.doc.layer(pixel).unwrap().surface().unwrap(); + let drift = mean_difference(stored, fresh); + assert!(drift < 1.0, "{cmd}: a re-render drifts {drift:.2}/255 from the converted rendering"); + let (off, baseline) = (mean_difference(fresh, reference), mean_difference(stored, reference)); + assert!( + off <= baseline + 0.5, + "{cmd}: the re-render is {off:.2}/255 from a pixel layer converted the same way, the converted rendering {baseline:.2}/255" + ); + } +} diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/it/smart_psd.rs similarity index 99% rename from crates/engine/tests/smart_psd.rs rename to crates/engine/tests/it/smart_psd.rs index 5bba21f122..4aeda1519b 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/it/smart_psd.rs @@ -331,7 +331,7 @@ fn photoshop_samples() { } /// PSD export of a 24 MP smart object versus the same layer as pixels (release: -/// `cargo test --release -p photocraft-engine --test smart_psd -- --ignored --nocapture perf`). +/// `cargo test --release -p photocraft-engine --test it -- --ignored --nocapture smart_psd::perf`). #[test] #[ignore = "benchmark"] fn perf_24mp_smart_object_export() { diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/it/style_and_align.rs similarity index 100% rename from crates/engine/tests/style_and_align.rs rename to crates/engine/tests/it/style_and_align.rs diff --git a/crates/engine/tests/svg_smart_object.rs b/crates/engine/tests/it/svg_smart_object.rs similarity index 100% rename from crates/engine/tests/svg_smart_object.rs rename to crates/engine/tests/it/svg_smart_object.rs diff --git a/crates/engine/tests/transform_budget_unclamped.rs b/crates/engine/tests/it/transform_budget_unclamped.rs similarity index 100% rename from crates/engine/tests/transform_budget_unclamped.rs rename to crates/engine/tests/it/transform_budget_unclamped.rs diff --git a/crates/engine/tests/type_format_targets.rs b/crates/engine/tests/it/type_format_targets.rs similarity index 100% rename from crates/engine/tests/type_format_targets.rs rename to crates/engine/tests/it/type_format_targets.rs diff --git a/crates/engine/tests/it/type_postscript_font.rs b/crates/engine/tests/it/type_postscript_font.rs new file mode 100644 index 0000000000..06171d7d84 --- /dev/null +++ b/crates/engine/tests/it/type_postscript_font.rs @@ -0,0 +1,94 @@ +//! `type.create` accepts an exact PostScript face name in its font field. + +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::Session; +use serde_json::json; + +fn font_with_postscript_name(source: &[u8], postscript: &str) -> Vec { + let mut bytes = source.to_vec(); + let count = u16::from_be_bytes(bytes[4..6].try_into().unwrap()) as usize; + let name_record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..r + 4] == b"name").unwrap(); + for i in 0..count { + let r = 12 + i * 16; + let offset = u32::from_be_bytes(bytes[r + 8..r + 12].try_into().unwrap()) as usize; + if &bytes[r..r + 4] == b"OS/2" { + bytes[offset + 4..offset + 6].copy_from_slice(&900u16.to_be_bytes()); + } + } + let names = [(1u16, "PhotoCraft Black Test"), (2, "Black"), (6, postscript), (16, "PhotoCraft Black Test"), (17, "Black")]; + let mut table = Vec::new(); + let mut strings = Vec::new(); + for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] { + table.extend(value.to_be_bytes()); + } + for (id, value) in names { + let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for n in [3u16, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] { + table.extend(n.to_be_bytes()); + } + strings.extend(encoded); + } + table.extend(strings); + let offset = bytes.len() as u32; + bytes[name_record + 8..name_record + 12].copy_from_slice(&offset.to_be_bytes()); + bytes[name_record + 12..name_record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes()); + bytes.extend(table); + bytes +} + +#[test] +fn type_create_resolves_exact_postscript_font_names() { + let ps = "PhotoCraft-Black-Test"; + photocraft_text::shared() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fonts + .register_font_data(font_with_postscript_name(photocraft_text::fonts::INTER_REGULAR.as_slice(), ps)); + + let mut session = Session::new(); + session.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let response = session.execute("type.create", json!({"text": "Test", "font": ps})).unwrap(); + assert!(response["warnings"].as_array().is_none_or(Vec::is_empty), "{response}"); + let id = LayerId(response["layer"].as_u64().unwrap()); + let LayerContent::Text(text) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") }; + let style = &text.char_runs()[0].style; + assert_eq!(style.font_family, "PhotoCraft Black Test"); + assert_eq!(style.postscript_name.as_deref(), Some(ps)); + assert_eq!(style.weight, 900); + + let regular = session.execute("type.create", json!({"text": "Test", "font": "Inter"})).unwrap(); + assert!(regular["warnings"].as_array().is_none_or(Vec::is_empty), "{regular}"); + let missing = session.execute("type.create", json!({"text": "Test", "font": "No Such Family 123"})).unwrap(); + assert_eq!(missing["warnings"], json!(["font 'No Such Family 123' is not installed; text may render using a fallback face"])); +} + +#[test] +fn exact_postscript_face_clears_retained_weight_variation() { + let ps = "PhotoCraft-Black-Test-Var"; + photocraft_text::shared() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fonts + .register_font_data(font_with_postscript_name(photocraft_text::fonts::INTER_REGULAR.as_slice(), ps)); + + let mut session = Session::new(); + session.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let response = session.execute("type.create", json!({"text": "Test", "font": "Inter", "variations": {"wght": 300}})).unwrap(); + let id = LayerId(response["layer"].as_u64().unwrap()); + + // Setting an exact PostScript face should clear the previously retained wght variation + session.execute("type.setStyle", json!({"layer": id.0, "font": ps})).unwrap(); + let LayerContent::Text(text) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") }; + let style = &text.char_runs()[0].style; + assert_eq!(style.font_family, "PhotoCraft Black Test"); + assert_eq!(style.weight, 900); + assert!(!style.variations.iter().any(|v| v.axis == "wght")); + + // Supplying a variations.wght in the same call should apply after clearing + session.execute("type.setStyle", json!({"layer": id.0, "font": ps, "variations": {"wght": 700}})).unwrap(); + let LayerContent::Text(text2) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") }; + let style2 = &text2.char_runs()[0].style; + assert_eq!(style2.font_family, "PhotoCraft Black Test"); + assert_eq!(style2.weight, 900); + assert_eq!(style2.variations.iter().find(|v| v.axis == "wght").map(|v| v.value), Some(700.0)); +} diff --git a/crates/engine/tests/undo_keeps_active_layer.rs b/crates/engine/tests/it/undo_keeps_active_layer.rs similarity index 100% rename from crates/engine/tests/undo_keeps_active_layer.rs rename to crates/engine/tests/it/undo_keeps_active_layer.rs diff --git a/crates/engine/tests/unicode_colors.rs b/crates/engine/tests/it/unicode_colors.rs similarity index 100% rename from crates/engine/tests/unicode_colors.rs rename to crates/engine/tests/it/unicode_colors.rs diff --git a/crates/engine/tests/it/unsupported_smart_filters.rs b/crates/engine/tests/it/unsupported_smart_filters.rs new file mode 100644 index 0000000000..8861fea4d0 --- /dev/null +++ b/crates/engine/tests/it/unsupported_smart_filters.rs @@ -0,0 +1,85 @@ +use photocraft_doc::{LayerContent, LayerId, SmartFilter}; +use photocraft_engine::{Session, smart_cmds}; +use serde_json::json; + +const STORED: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +fn mockup() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("imported filter", |doc, _| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("not a smart object") }; + sm.smart_filters.push(SmartFilter { + command: "psd.unsupportedFilter".into(), + params: json!({"name": "Sharpen", "filterID": 1399353968, "psd": ""}), + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }); + let c = sm.cache.as_mut().unwrap(); + let r = c.content_bounds(); + c.fill_rect(r, &STORED); + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn cache(s: &Session, id: LayerId) -> photocraft_raster::Surface { + let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a smart object") }; + sm.cache.clone().unwrap() +} + +fn png() -> Vec { + let image = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, vec![0; 8 * 8 * 3]).unwrap(); + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} + +#[test] +fn geometry_edits_keep_the_stored_render_of_an_unsupported_filter() { + for (cmd, params) in [("edit.transform", json!({"scaleX": 50, "scaleY": 50})), ("image.imageSize", json!({"width": 20, "height": 15}))] { + let (mut s, id) = mockup(); + let before = cache(&s, id).content_bounds(); + s.execute(cmd, params).unwrap(); + let after = cache(&s, id); + let r = after.content_bounds(); + assert!(r.width() < before.width(), "{cmd} resized the stored render"); + assert_eq!(after.rgba((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2), STORED, "{cmd} kept the stored render"); + } +} + +#[test] +fn replacing_contents_reports_the_filters_it_cannot_apply() { + let (mut s, id) = mockup(); + let r = smart_cmds::replace_contents_bytes(&mut s, id, "new.png", png()).unwrap(); + let warnings = r["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].as_str().unwrap().contains("Sharpen"), "{r}"); +} + +#[test] +fn supported_filters_still_re_render_after_a_transform() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("stale cache", |doc, _| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("not a smart object") }; + let c = sm.cache.as_mut().unwrap(); + let r = c.content_bounds(); + c.fill_rect(r, &STORED); + Ok(()) + }) + .unwrap(); + s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50})).unwrap(); + let c = cache(&s, id); + let r = c.content_bounds(); + assert_ne!(c.rgba((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2), STORED, "a supported filter re-renders from the source"); +} diff --git a/crates/engine/tests/wand_transparent_copy.rs b/crates/engine/tests/it/wand_transparent_copy.rs similarity index 100% rename from crates/engine/tests/wand_transparent_copy.rs rename to crates/engine/tests/it/wand_transparent_copy.rs diff --git a/crates/engine/tests/working_rgb_profile.rs b/crates/engine/tests/it/working_rgb_profile.rs similarity index 100% rename from crates/engine/tests/working_rgb_profile.rs rename to crates/engine/tests/it/working_rgb_profile.rs diff --git a/crates/engine/tests/it/xmp_charref.rs b/crates/engine/tests/it/xmp_charref.rs new file mode 100644 index 0000000000..a4b98da4e5 --- /dev/null +++ b/crates/engine/tests/it/xmp_charref.rs @@ -0,0 +1,24 @@ +use photocraft_engine::file_cmds::read_file_info; + +#[test] +fn numeric_character_references_are_decoded() { + let xmp = "\ + Line one Line two © & co A &#38;\ + "; + let info = read_file_info(Some(xmp)); + assert_eq!(info["description"], "Line one\nLine two © & co A &"); +} + +/// Many `&` with no `;` near them stay literal, and are scanned a bounded distance each (a +/// multi-megabyte XMP once made this quadratic). +#[test] +fn a_long_run_of_ampersands_without_semicolons_is_left_unchanged() { + let run = "&".repeat(200_000); + let xmp = format!( + "\ + {run}\ + " + ); + let info = read_file_info(Some(&xmp)); + assert_eq!(info["description"], run.as_str()); +} diff --git a/crates/engine/tests/live_dab_matches_replay.rs b/crates/engine/tests/live_dab_matches_replay.rs new file mode 100644 index 0000000000..aae7e32b53 --- /dev/null +++ b/crates/engine/tests/live_dab_matches_replay.rs @@ -0,0 +1,216 @@ +use photocraft_engine::{Session, retouch_cmds::LiveDab}; +use photocraft_paint::StrokePoint; +use serde_json::json; + +fn setup(depth: u32, mode: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":200,"height":120,"depth":depth,"mode":mode})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("texture", |doc, active| { + let bounds = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let mut px = Vec::new(); + for y in 0..120 { + for x in 0..200 { + px.extend(photocraft_raster::from_rgba( + &fmt, + [((x * 7 + y * 3) % 31) as f32 / 31.0, x as f32 / 200.0, y as f32 / 120.0, 0.4 + ((x + y) % 7) as f32 / 12.0], + )); + } + } + surf.write_region(bounds, &px); + Ok(()) + }) + .unwrap(); + s +} + +fn pixels(s: &Session) -> Vec { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) +} + +#[test] +fn pressure_shape_and_smoothing_match_replay() { + let mut failures = Vec::new(); + for cmd in ["paint.smudge", "paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge"] { + for case in ["pressure", "locked", "ellipse", "smoothing", "scattering"] { + for depth in [8, 16, 32] { + let mut s = setup(depth, "rgb"); + match case { + "locked" => { + s.edit("lock alpha", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + } + "ellipse" => { + s.tools.brush.roundness = 0.2; + s.tools.brush.angle = 37.0; + } + "smoothing" => { + s.tools.brush.smoothing.amount = 0.6; + } + "scattering" => { + s.tools.brush.scattering.enabled = true; + s.tools.brush.scattering.scatter.jitter = 1.0; + } + _ => {} + } + let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]]; + let params = json!({"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25}); + s.execute(cmd, params.clone()).unwrap(); + let want = pixels(&s); + s.execute("edit.undo", json!({})).unwrap(); + let mut start = params.clone(); + start["points"] = json!([points[0]]); + let mut live = LiveDab::begin(&s, cmd, &start).unwrap(); + for q in &points[1..] { + live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap(); + } + s.offer_live_dab(live).unwrap(); + let result = s.execute(cmd, params).unwrap(); + assert_eq!(result["fromLive"], true); + let got = pixels(&s); + let count = got.iter().zip(&want).filter(|(a, b)| a != b).count(); + let max = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + if count > 0 { + failures.push(format!("{cmd} {case} depth={depth}: {count} channels differ, max={max}")); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn native_modes_selection_and_lock_match_replay() { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = setup(depth, mode); + s.edit("lock alpha", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x":30,"y":20,"width":110,"height":60})).unwrap(); + let points = [[-10.0, 30.0], [80.0, 50.0], [160.0, 80.0], [60.0, 60.0]]; + for cmd in ["paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge", "paint.smudge"] { + let params = json!({"points":points,"size":30,"hardness":40,"strength":70,"opacity":37}); + let expected_damage = s.execute(cmd, params.clone()).unwrap()["damage"].clone(); + let want = pixels(&s); + s.execute("edit.undo", json!({})).unwrap(); + let mut start = params.clone(); + start["points"] = json!([points[0]]); + let mut live = LiveDab::begin(&s, cmd, &start).unwrap(); + for q in &points[1..] { + live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap(); + } + s.offer_live_dab(live).unwrap(); + let result = s.execute(cmd, params).unwrap(); + assert_eq!(result["fromLive"], true); + assert_eq!(result["damage"], expected_damage); + assert_eq!(pixels(&s), want, "{mode} {depth} {cmd}"); + s.execute("edit.undo", json!({})).unwrap(); + } + } + } +} + +#[test] +fn changed_document_refuses_live_pixels() { + let mut s = setup(8, "rgb"); + let p = json!({"points":[[40,40],[80,60]],"size":30}); + let live = LiveDab::begin(&s, "paint.blur", &p).unwrap(); + s.execute("paint.stroke", json!({"points":[[40,40],[80,60]],"color":"#ffffff","size":20})).unwrap(); + let mut replay = Session::new(); + replay.add_document((*s.active().unwrap().doc).clone(), None); + replay.tools.brush = s.tools.brush.clone(); + s.offer_live_dab(live).unwrap(); + assert!(s.execute("paint.blur", p.clone()).unwrap().get("fromLive").is_none()); + replay.execute("paint.blur", p).unwrap(); + assert_eq!(pixels(&s), pixels(&replay)); +} + +/// An independent paint-level oracle gives Smudge a generously padded working region. Pixels +/// picked up by the initial small dab must not depend on the final stroke's bounding rectangle. +#[test] +fn smudge_pickup_matches_full_neighborhood_when_pressure_expands() { + use photocraft_paint::{ + Stroke, + retouch::{Smudge, apply_dab_stroke}, + }; + + let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]]; + for depth in [8, 16, 32] { + let mut s = setup(depth, "rgb"); + let params = json!({"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25}); + let (mut expected, lock) = { + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + (st.doc.layer(id).unwrap().surface().unwrap().clone(), st.doc.effective_locks(id).transparency) + }; + let stroke = Stroke { + brush: photocraft_engine::retouch_cmds::stroke_brush(&s.tools.brush, ¶ms), + points: points.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect(), + }; + let mut effect = Smudge::new(0.8, None, 40.0).with_alpha(Some(3)); + apply_dab_stroke(&mut expected, &stroke, None, lock, 40, |work, fp| effect.dab(work, fp)); + let bounds = s.active().unwrap().doc.bounds(); + let want = expected.read_region(bounds); + s.execute("paint.smudge", params.clone()).unwrap(); + assert!(pixels(&s) == want, "command must read the whole pickup square at depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + let live = LiveDab::begin(&s, "paint.smudge", ¶ms).unwrap(); + s.offer_live_dab(live).unwrap(); + assert_eq!(s.execute("paint.smudge", params).unwrap()["fromLive"], true); + assert!(pixels(&s) == want, "a single batch uses the same pickup square at depth {depth}"); + } +} + +#[test] +fn pressure_sized_live_dabs_commit_only_the_requested_mask_or_channel() { + use photocraft_raster::Surface; + + let fill = |surface: &mut Surface| { + let values: Vec = (0..120).flat_map(|y| (0..200).map(move |x| ((x * 7 + y * 3) % 31) as f32 / 31.0)).collect(); + surface.write_region(photocraft_geom::Rect::new(0, 0, 200, 120), &values); + }; + for depth in [8, 16, 32] { + let mut s = setup(depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.new", json!({"fill":"white"})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on":true})).unwrap(); + s.edit("mask texture", |doc, active| { + fill(&mut doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface); + fill(&mut doc.channels[0].surface); + fill(&mut doc.quick_mask.as_mut().unwrap().surface); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + for target in [json!("mask"), json!({"channel":0}), json!("quickMask")] { + for cmd in ["paint.smudge", "paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge"] { + let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]]; + let params = json!({"target":target,"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25}); + s.execute(cmd, params.clone()).unwrap(); + let want = s.active().unwrap().doc.clone(); + s.execute("edit.undo", json!({})).unwrap(); + let mut start = params.clone(); + start["points"] = json!([points[0]]); + let mut live = LiveDab::begin(&s, cmd, &start).unwrap(); + for q in &points[1..] { + live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap(); + } + s.offer_live_dab(live).unwrap(); + assert_eq!(s.execute(cmd, params).unwrap()["fromLive"], true); + assert_eq!(s.active().unwrap().doc, want, "depth={depth} {cmd} target={target}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + } + } + } +} diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index 893ba5c23d..91f42be306 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -30,9 +30,14 @@ static PANICS: AtomicUsize = AtomicUsize::new(0); /// One run at a time, so a panic is charged to the run that caused it. static RUN: Mutex<()> = Mutex::new(()); -/// Run `f` on a worker thread. `None` if it timed out, else whether it finished without -/// panicking, contained or not (returning Ok or a graceful Err both count). -fn run_watched(f: impl FnOnce() + Send + 'static) -> Option { +/// Budget for the self-test below, which checks how panics are counted, not hangs. The panic hook +/// prints a backtrace under `RUST_BACKTRACE=1` (as in CI), and on a loaded Windows runner the +/// first symbol lookup alone can take 15–20 s. +const SELF_TEST_DEADLINE: Duration = Duration::from_secs(120); + +/// Run `f` on a worker thread. `None` if it took longer than `deadline`, else whether it +/// finished without panicking, contained or not (returning Ok or a graceful Err both count). +fn run_watched(deadline: Duration, f: impl FnOnce() + Send + 'static) -> Option { static HOOK: Once = Once::new(); HOOK.call_once(|| { let default = std::panic::take_hook(); @@ -48,13 +53,13 @@ fn run_watched(f: impl FnOnce() + Send + 'static) -> Option { let r = catch_unwind(AssertUnwindSafe(f)); let _ = tx.send(r.is_ok()); }); - rx.recv_timeout(DEADLINE).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before) + rx.recv_timeout(deadline).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before) } /// Run `id` with `p` on a fresh session; see [`run_watched`]. fn run_once(id: &str, p: &Value) -> Option { let (id, p) = (id.to_string(), p.clone()); - run_watched(move || { + run_watched(DEADLINE, move || { let mut s = fresh(); let _ = s.execute(&id, p); }) @@ -107,11 +112,11 @@ fn no_command_panics_or_hangs_on_adversarial_params() { /// #4982: a panic the code under test contains still fails the run; a graceful `Err` does not. #[test] fn contained_panics_count_and_graceful_errors_do_not() { - let contained = run_watched(|| { + let contained = run_watched(SELF_TEST_DEADLINE, || { let _ = catch_unwind(|| panic!("contained, as Session::execute does")); }); assert_eq!(contained, Some(false)); - let graceful = run_watched(|| { + let graceful = run_watched(SELF_TEST_DEADLINE, || { let mut s = fresh(); assert!(s.execute("layer.delete", json!({"layer": 999999})).is_err()); }); diff --git a/crates/engine/tests/smart_rerender_fallback.rs b/crates/engine/tests/smart_rerender_fallback.rs new file mode 100644 index 0000000000..1359ec0aeb --- /dev/null +++ b/crates/engine/tests/smart_rerender_fallback.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use photocraft_doc::{LayerContent, SmartObject, SmartSource}; +use photocraft_engine::{EngineError, Session}; +use serde_json::{Value, json}; + +fn deny_blur(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "filter.blur.gaussianBlur" { Err(EngineError::Other("denied".into())) } else { Ok(()) } +} + +fn edits() -> [(&'static str, Value); 2] { + [("edit.transform", json!({"scaleX": 50, "scaleY": 100})), ("image.imageSize", json!({"width": 100, "height": 50}))] +} + +fn blurred_smart_object() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + s +} + +fn cache_width(s: &Session) -> i32 { + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; + sm.cache.as_ref().unwrap().content_bounds().width() as i32 +} + +fn edit_smart_object(s: &mut Session, edit: impl FnOnce(&mut SmartObject)) { + let st = s.active_mut().unwrap(); + let id = st.active_layer.unwrap(); + let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut st.doc).layer_mut(id).unwrap().content else { panic!("not a smart object") }; + edit(sm); +} + +#[test] +fn a_smart_object_whose_filters_are_denied_keeps_the_resampled_fallback() { + for (cmd, params) in edits() { + let mut s = blurred_smart_object(); + let before = cache_width(&s); + s.authorize = Some(deny_blur); + s.execute(cmd, params).unwrap(); + let after = cache_width(&s); + assert!(after < before * 3 / 4, "{cmd}: the fallback stayed at {after} px wide (was {before})"); + } +} + +#[test] +fn a_smart_object_that_fails_to_re_render_keeps_the_resampled_fallback() { + let unrenderable = + [SmartSource::Linked { path: "missing.psd".into() }, SmartSource::Embedded { file_name: "broken.png".into(), bytes: Arc::new(vec![0; 16]) }]; + for (cmd, params) in edits() { + for source in &unrenderable { + let mut s = blurred_smart_object(); + let before = cache_width(&s); + edit_smart_object(&mut s, |sm| sm.source = source.clone()); + s.execute(cmd, params.clone()).unwrap(); + let after = cache_width(&s); + assert!(after < before * 3 / 4, "{cmd} with {source:?}: the fallback stayed at {after} px wide (was {before})"); + } + } +} + +#[test] +fn an_allowed_smart_object_still_re_renders_at_the_new_size() { + for (cmd, params) in edits() { + let mut s = blurred_smart_object(); + let before = cache_width(&s); + s.execute(cmd, params).unwrap(); + let after = cache_width(&s); + assert!(after < before * 3 / 4, "{cmd}: {after} px wide (was {before})"); + } +} diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 3a4aeca6c0..8cd349efff 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -2,8 +2,9 @@ //! //! [`atomic_write`] never truncates the destination. It writes a temporary file in the **same //! directory**, flushes it to stable storage (`sync_all`), renames it over the destination and, -//! on Unix, fsyncs the directory so the rename itself survives a power loss. If anything fails, -//! the destination keeps its previous bytes and the temporary file is removed. +//! flushes the directory so the rename itself survives a power loss. Failures before replacement +//! keep the destination's previous bytes. On Windows a failed publication flush reports that the +//! new file is in place but its durability could not be confirmed (#7858). //! //! - **Windows:** antivirus scanners and the search indexer briefly hold files open, which makes //! the replacing rename fail with `PermissionDenied`. The rename is retried with exponential @@ -59,7 +60,7 @@ pub trait AtomicFs { fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; /// Remove a (temporary) file. fn remove(&self, path: &Path) -> io::Result<()>; - /// Flush a directory entry change (Unix); a no-op elsewhere. + /// Flush a directory entry change on Unix and Windows; a no-op elsewhere. fn sync_dir(&self, dir: &Path) -> io::Result<()>; /// Copy the metadata a replacement should keep — mode, extended attributes, ACL — from the /// file about to be replaced onto the temporary file. Best effort; see [`meta`]. @@ -100,12 +101,27 @@ impl AtomicFs for RealFs { fn remove(&self, path: &Path) -> io::Result<()> { std::fs::remove_file(path) } + /// Flush directory publication using a directory handle on Unix and Windows. + /// Windows requests only `FILE_ADD_FILE`; opening or flushing the handle can fail the save. fn sync_dir(&self, dir: &Path) -> io::Result<()> { #[cfg(unix)] { - sync_file(&std::fs::File::open(dir)?) + open_dir_for_sync(dir)?.map_or(Ok(()), |d| sync_file(&d)) } - #[cfg(not(unix))] + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + // FILE_ADD_FILE is the directory equivalent of FILE_WRITE_DATA. Requesting only + // this right permits FlushFileBuffers without requiring GENERIC_WRITE's additional + // directory permissions. BACKUP_SEMANTICS opens a directory, not a volume: no + // administrative privilege or volume-wide flush is needed. + const FILE_ADD_FILE: u32 = 0x0002; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + let directory = std::fs::OpenOptions::new().access_mode(FILE_ADD_FILE).custom_flags(FILE_FLAG_BACKUP_SEMANTICS).open(dir)?; + directory.sync_all() + } + #[cfg(not(any(unix, windows)))] { let _ = dir; Ok(()) @@ -172,8 +188,8 @@ impl RenameRetry { /// Atomically replace (or create) `path` with `bytes`. See the module docs. /// -/// Errors keep their [`io::ErrorKind`] and say what happened and that the original file is -/// unchanged. +/// Errors keep their [`io::ErrorKind`]. Before replacement they say the original is unchanged; +/// a Windows publication-flush error says the new file may not survive a power loss. pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { atomic_write_with(&RealFs, RenameRetry::platform(), path, bytes) } @@ -238,12 +254,79 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & let _ = fs.remove(&tmp); return Err(rename_error(e, path)); } - // The new bytes are in place; a failed directory fsync can't be undone and must not report - // the save as failed, so it is best effort. - let _ = fs.sync_dir(&dir); + // The new bytes are in place, but until the directory is flushed the rename may not survive + // a power loss (#4968). Windows fails the save on any failed flush (#7858). Elsewhere a file + // system that can't flush directories at all counts as done; any other failure is reported, + // so the caller keeps the document unsaved and its recovery data instead of discarding them + // on a save that may not last. + #[cfg(windows)] + fs.sync_dir(&dir).map_err(|e| publication_error(e, path))?; + #[cfg(not(windows))] + dir_synced(fs.sync_dir(&dir)).map_err(|e| unconfirmed_error(e, path))?; Ok(()) } +/// `dir` opened to flush it, or `None` when it can't be opened for that because it has no read +/// permission (a write-and-search-only folder): no flush can be attempted there, so none failed. +/// Any other error is returned. +#[cfg(unix)] +fn open_dir_for_sync(dir: &Path) -> io::Result> { + match std::fs::File::open(dir) { + Ok(d) => Ok(Some(d)), + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => Ok(None), + Err(e) => Err(e), + } +} + +/// `result` of flushing a directory, with "this file system can't flush directories" counted as +/// success: `EINVAL` (Linux file systems without directory fsync), `ENOTSUP`/`EOPNOTSUPP` and, +/// on Apple platforms, `ENOTTY`, or [`io::ErrorKind::Unsupported`]. Waiting or retrying won't +/// change those, and failing every save there would make saving impossible. Any other error is +/// returned: the flush was attempted and failed. +#[cfg(not(windows))] +pub(crate) fn dir_synced(result: io::Result<()>) -> io::Result<()> { + match result { + Err(e) if dir_sync_unsupported(&e) => Ok(()), + other => other, + } +} + +#[cfg(not(windows))] +fn dir_sync_unsupported(e: &io::Error) -> bool { + if e.kind() == io::ErrorKind::Unsupported { + return true; + } + // errno values: EINVAL is 22 everywhere; ENOTSUP and EOPNOTSUPP are 95 on Linux and + // Android, 45 and 102 on Apple platforms, 45 on the BSDs; ENOTTY is 25. Elsewhere 45 means + // something else (illumos: EDEADLK), so only EINVAL and `Unsupported` count there. + const EINVAL: i32 = 22; + let unsupported: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) { + &[EINVAL, 95] + } else if cfg!(target_vendor = "apple") { + &[EINVAL, 45, 102, 25] + } else if cfg!(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")) { + &[EINVAL, 45] + } else if cfg!(unix) { + &[EINVAL] + } else { + &[] + }; + e.raw_os_error().is_some_and(|code| unsupported.contains(&code)) +} + +/// The error for a file that was published but whose directory flush failed: same kind, saying +/// the file is in place but may not survive a power loss. (Windows reports `publication_error`.) +#[cfg(not(windows))] +fn unconfirmed_error(e: io::Error, path: &Path) -> io::Error { + context( + e, + format!( + "{} was written, but the folder could not be flushed to disk, so the save may not survive a power loss or crash; save again or to another folder", + path.display() + ), + ) +} + /// Run `rename`, retrying with backoff while it fails with `PermissionDenied` (see /// [`RenameRetry`]). For callers that rename through their own handles (capability directories). pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> { @@ -316,6 +399,15 @@ fn context(e: io::Error, msg: String) -> io::Error { const UNCHANGED: &str = "the original file was not changed"; +/// Replacement already happened: never claim that the original file is unchanged or undo it. +#[cfg(windows)] +pub(crate) fn publication_error(error: io::Error, path: &Path) -> io::Error { + io::Error::new( + error.kind(), + format!("wrote {}, but could not confirm the save is durable; it may not survive a power loss or crash: {error}", path.display()), + ) +} + fn write_error(e: io::Error, path: &Path, dir: &Path) -> io::Error { let msg = match e.kind() { io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => { diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index d4381705a0..bc26ae6d0a 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -41,6 +41,10 @@ struct Faulty { /// Fail the first N renames with PermissionDenied (a transient lock), then succeed. locked_renames: Cell, renames: Cell, + /// Fail the directory flush (the durability barrier after publication, #4968, #7858) + /// with this raw OS error, or with this kind. + fail_sync_dir: Option>, + synced: Cell, } impl AtomicFs for Faulty { @@ -66,8 +70,14 @@ impl AtomicFs for Faulty { fn remove(&self, path: &Path) -> io::Result<()> { RealFs.remove(path) } + /// Count publication barriers and optionally fail before the real directory flush. fn sync_dir(&self, dir: &Path) -> io::Result<()> { - RealFs.sync_dir(dir) + self.synced.set(self.synced.get() + 1); + match self.fail_sync_dir { + Some(Ok(code)) => Err(io::Error::from_raw_os_error(code)), + Some(Err(kind)) => Err(io::Error::new(kind, "injected directory flush failure")), + None => RealFs.sync_dir(dir), + } } fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { RealFs.copy_metadata(from, to) @@ -76,6 +86,39 @@ impl AtomicFs for Faulty { const NO_RETRY: RenameRetry = RenameRetry { attempts: 0, first_delay_ms: 0 }; +/// A successful rename does not establish power-loss durability. A failed Windows publication +/// barrier must leave the new bytes in place, report uncertainty, and allow a subsequent retry. +#[cfg(windows)] +#[test] +fn a_failed_publication_flush_is_reported_and_can_be_retried() { + let d = TempDir::new("publication-flush"); + let p = d.0.join("document.pcraft"); + std::fs::write(&p, ORIGINAL).unwrap(); + let fs = Faulty { fail_sync_dir: Some(Err(io::ErrorKind::Other)), ..Default::default() }; + + let error = atomic_write_with(&fs, NO_RETRY, &p, b"new document").unwrap_err(); + assert_eq!(fs.synced.get(), 1, "{error}"); + assert_eq!(error.kind(), io::ErrorKind::Other); + assert!(error.to_string().contains("document.pcraft"), "{error}"); + assert!(error.to_string().contains("may not survive"), "{error}"); + assert!(!error.to_string().contains(UNCHANGED), "the replacement already happened: {error}"); + assert_eq!(std::fs::read(&p).unwrap(), b"new document"); + assert_no_temp(&d.0); + + atomic_write(&p, b"retried document").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"retried document"); + assert_no_temp(&d.0); +} + +/// A real Windows directory handle must flush successfully and preserve directory-open errors. +#[cfg(windows)] +#[test] +fn windows_directory_flush_opens_a_directory_and_reports_open_errors() { + let d = TempDir::new("sync-directory"); + RealFs.sync_dir(&d.0).unwrap(); + assert_eq!(RealFs.sync_dir(&d.0.join("missing")).unwrap_err().kind(), io::ErrorKind::NotFound); +} + #[test] fn replaces_and_creates() { let d = TempDir::new("ok"); @@ -113,6 +156,7 @@ fn creates_missing_parent_folders() { assert_eq!(std::fs::read(&p).unwrap(), b"x"); } +/// A partial temporary write must preserve the original and never reach publication or its flush. #[test] fn write_failure_halfway_keeps_original() { let d = TempDir::new("write"); @@ -124,9 +168,11 @@ fn write_failure_halfway_keeps_original() { assert!(e.to_string().contains("not changed"), "{e}"); assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); assert_eq!(fs.renames.get(), 0, "never renames after a failed write"); + assert_eq!(fs.synced.get(), 0, "nothing was published"); assert_no_temp(&d.0); } +/// A failed replacement must preserve the original and skip the publication barrier. #[test] fn rename_failure_keeps_original() { let d = TempDir::new("rename"); @@ -134,6 +180,7 @@ fn rename_failure_keeps_original() { std::fs::write(&p, ORIGINAL).unwrap(); let fs = Faulty { fail_rename: Some(io::ErrorKind::Other), ..Default::default() }; assert!(atomic_write_with(&fs, NO_RETRY, &p, b"new").is_err()); + assert_eq!(fs.synced.get(), 0, "a failed replacement never reaches the publication barrier"); assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); assert_no_temp(&d.0); } @@ -283,6 +330,19 @@ fn keeps_the_original_file_mode() { assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); } +#[cfg(unix)] +#[test] +fn new_file_follows_the_process_umask() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("umask"); + let probe = d.0.join("probe.psd"); + std::fs::File::create(&probe).unwrap(); + let expected = std::fs::metadata(&probe).unwrap().permissions().mode() & 0o777; + let p = d.0.join("fresh.psd"); + atomic_write(&p, b"new").unwrap(); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, expected); +} + #[cfg(unix)] #[test] fn writes_through_a_symlink() { @@ -386,3 +446,38 @@ fn sync_file_flushes_a_local_file() { let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); sync_file(&file).unwrap(); } + +/// #4968: the rename succeeded but the directory flush failed. The new bytes are in place, and +/// the save is reported as unconfirmed (same kind, saying so) instead of as a success, so the +/// caller keeps the document unsaved and its recovery data. +#[test] +fn a_failed_directory_flush_is_reported_not_swallowed() { + let d = TempDir::new("syncdir"); + let p = d.0.join("doc.pcraft"); + std::fs::write(&p, ORIGINAL).unwrap(); + let fs = Faulty { fail_sync_dir: Some(Err(io::ErrorKind::Other)), ..Default::default() }; + let e = atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::Other); + let msg = e.to_string(); + assert!(msg.contains("doc.pcraft") && msg.contains("may not survive"), "{msg}"); + assert_eq!(std::fs::read(&p).unwrap(), b"new", "published: the new bytes are in place"); + assert_no_temp(&d.0); +} + +/// A file system that can't flush directories at all (Linux's EINVAL, ENOTSUP, or an +/// `Unsupported` error) still saves: failing every save there would make saving impossible. +/// Windows fails the save on any failed flush (#7858), so this is the Unix rule. +#[cfg(not(windows))] +#[test] +fn a_directory_flush_the_file_system_cannot_do_still_saves() { + let d = TempDir::new("syncdir-unsupported"); + let p = d.0.join("doc.pcraft"); + let fs = Faulty { fail_sync_dir: Some(Err(io::ErrorKind::Unsupported)), ..Default::default() }; + atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"new"); + // EINVAL (22) means "can't flush a directory" only on Unix; EIO (5) is a real failure. + assert_eq!(dir_synced(Err(io::Error::from_raw_os_error(22))).is_ok(), cfg!(unix)); + assert!(dir_synced(Err(io::Error::from_raw_os_error(5))).is_err()); + let fs = Faulty { fail_sync_dir: Some(Ok(5)), ..Default::default() }; + assert!(atomic_write_with(&fs, NO_RETRY, &p, b"newer").is_err()); +} diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index fa3c9a4085..c5ba725edc 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -9,6 +9,7 @@ //! [`RecoveryStore`] is the app-facing lifecycle: an entry is only deleted once //! the document is saved or closed, never just because it was recovered. +use std::collections::BTreeSet; #[cfg(not(target_arch = "wasm32"))] use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -20,7 +21,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use photocraft_doc::Document; use serde::{Deserialize, Serialize}; -use crate::store::write_atomic; use crate::{FormatError, PcraftWriter, Result, SaveOptions, SaveStats}; /// Sidecar describing an autosave. @@ -168,7 +168,7 @@ fn worker(rx: Receiver, bundle: PathBuf, sidecar: PathBuf, last: Arc, bundle: PathBuf, sidecar: PathBuf, last: Arc Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + struct PrivateFs; + impl crate::atomic::AtomicFs for PrivateFs { + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(tmp)?; + f.write_all(bytes)?; + crate::atomic::sync_file(&f) + } + fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.rename(from, to) + } + fn remove(&self, path: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.remove(path) + } + fn sync_dir(&self, dir: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.sync_dir(dir) + } + fn copy_metadata(&self, from: &Path, to: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.copy_metadata(from, to) + } + } + crate::atomic::atomic_write_with(&PrivateFs, crate::atomic::RenameRetry::platform(), path, bytes)?; + Ok(()) + } + #[cfg(not(unix))] + { + crate::store::write_atomic(path, bytes) + } +} + +fn read_sidecar_bounded(path: &Path, max: usize) -> Option> { + use std::io::Read; + #[cfg(unix)] + let mut file = { + use rustix::fs::{Mode, OFlags, open}; + let fd = open(path, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty()).ok()?; + let file = std::fs::File::from(fd); + if !file.metadata().ok()?.is_file() { + return None; + } + let _ = rustix::fs::fcntl_setfl(&file, OFlags::empty()); + file + }; + #[cfg(not(unix))] + let mut file = { + if !std::fs::symlink_metadata(path).ok()?.file_type().is_file() { + return None; + } + let file = std::fs::File::open(path).ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + file + }; + let mut bytes = Vec::new(); + (&mut file).take((max as u64).saturating_add(1)).read_to_end(&mut bytes).ok()?; + if bytes.len() > max { + return None; + } + Some(bytes) +} + /// Recoverable documents in `recovery_dir`, newest first. A sidecar must be named after its own /// (sanitized) key, so a stray or hand-edited one can never point outside the directory. +/// +/// Each key is looked at once, after the directory has been read: an autosave may publish a +/// document's first sidecar while the listing runs, and judging the bundle and the sidecar as +/// each name came up listed that bundle twice, or not at all. pub fn list_recovery(recovery_dir: &Path) -> Vec { - let mut out = Vec::new(); let Ok(rd) = std::fs::read_dir(recovery_dir) else { - return out; + return Vec::new(); }; - for e in rd.flatten() { - let p = e.path(); - if p.extension().is_some_and(|x| x == "json") - && let Ok(bytes) = std::fs::read(&p) - && let Ok(info) = serde_json::from_slice::(&bytes) - && sanitize(&info.key) == info.key - && p.file_stem().is_some_and(|stem| *stem == *info.key) - { - let bundle = recovery_dir.join(format!("{}.pcraft", info.key)); - if bundle.join(crate::store::MANIFEST).is_file() { - out.push(RecoveryEntry { info, bundle }); - } - } else if e.file_type().is_ok_and(|ft| ft.is_dir()) - && let Some(entry) = sidecarless_entry(recovery_dir, &p) - { - out.push(entry); - } - } + let keys: BTreeSet = rd + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + name.strip_suffix(".json").or_else(|| name.strip_suffix(".pcraft")).map(str::to_string) + }) + .collect(); + let mut out: Vec = keys.iter().filter_map(|key| recovery_entry(recovery_dir, key)).collect(); out.sort_by(|a, b| b.info.saved_at.cmp(&a.info.saved_at).then(a.info.key.cmp(&b.info.key))); out } +/// `key`'s entry. The sidecar is tried again last: it can appear after the first look, once the +/// bundle it describes is committed, and is never removed by a save. +fn recovery_entry(recovery_dir: &Path, key: &str) -> Option { + let bundle = recovery_dir.join(format!("{key}.pcraft")); + sidecar_entry(recovery_dir, key, &bundle).or_else(|| sidecarless_entry(recovery_dir, &bundle)).or_else(|| sidecar_entry(recovery_dir, key, &bundle)) +} + +fn sidecar_entry(recovery_dir: &Path, key: &str, bundle: &Path) -> Option { + let bytes = read_sidecar_bounded(&recovery_dir.join(format!("{key}.json")), 1 << 20)?; + let info = serde_json::from_slice::(&bytes).ok()?; + (sanitize(&info.key) == info.key && info.key == key && bundle.join(crate::store::MANIFEST).is_file()) + .then(|| RecoveryEntry { info, bundle: bundle.to_path_buf() }) +} + /// A committed `.pcraft` bundle (its manifest is written last) whose sidecar was never /// published, e.g. a crash during a document's first autosave. The metadata is rebuilt from the /// manifest (read like a load reads it) and its modification time. Incomplete bundles (no /// readable manifest) and bundles with anything at the sidecar's path are not entries. fn sidecarless_entry(recovery_dir: &Path, bundle: &Path) -> Option { let key = bundle.file_name()?.to_str()?.strip_suffix(".pcraft")?; - if key.is_empty() || sanitize(key) != key || std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok() { + if !std::fs::symlink_metadata(bundle).is_ok_and(|m| m.is_dir()) + || key.is_empty() + || sanitize(key) != key + || std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok() + { return None; } let meta = std::fs::symlink_metadata(bundle.join(crate::store::MANIFEST)).ok().filter(|m| m.is_file())?; @@ -402,6 +479,9 @@ fn untouched_for(path: &Path, min_age: std::time::Duration, missing_ok: bool) -> } fn remove_entry(dir: &Path, key: &str) -> Result<()> { + if sanitize(key) != key { + return Err(FormatError::corrupt("recovery entry key contains path components")); + } let bundle = dir.join(format!("{key}.pcraft")); if bundle.exists() { std::fs::remove_dir_all(bundle)?; diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 3f21e7e47c..54f6a17081 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -42,6 +42,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 { @@ -161,6 +163,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)), @@ -211,6 +214,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { blend_if: l.blend_if.clone(), advanced: l.advanced, video: l.video.as_ref().map(|v| video_m(v, sink)), + xmp: l.xmp.as_deref().map(str::to_string), } } @@ -235,7 +239,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { ruler_origin: d.ruler_origin, selection: d.selection.as_ref().map(|s| surface_m(s, sink)), metadata: MetadataM { - xmp: d.metadata.xmp.clone(), + xmp: d.metadata.xmp.as_deref().map(str::to_owned), exif: opt_blob(&d.metadata.exif, sink), text: d.metadata.text.clone(), psd_resources: d.metadata.psd_resources.iter().map(|(id, n, b)| (*id, n.clone(), sink.blob(b))).collect(), @@ -478,33 +482,52 @@ impl Loader<'_> { 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) => { @@ -547,6 +570,7 @@ impl Loader<'_> { blend_if: m.blend_if.clone(), advanced: m.advanced, video: m.video.as_ref().map(|v| self.video(v)).transpose()?, + xmp: m.xmp.as_deref().map(Arc::from), }) } @@ -579,7 +603,7 @@ impl Loader<'_> { } let quick_mask = m.quick_mask.as_ref().map(|c| self.channel(c)).transpose()?; let mut md = Metadata { - xmp: m.metadata.xmp.clone(), + xmp: m.metadata.xmp.as_deref().map(std::sync::Arc::from), exif: self.opt_blob(&m.metadata.exif)?, text: m.metadata.text.clone(), psd_resources: Vec::new(), diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 0c87720212..992e925ae9 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -8,6 +8,7 @@ //! blobs/.zst content-addressed binary data (ICC, EXIF, PSD blocks, smart objects) //! thumb.png optional thumbnail (caller-rendered) //! composite/preview.png optional flattened preview (caller-rendered) +//! save.lock directory bundles only: held while a save runs (empty) //! ``` //! //! A bundle is either a ZIP archive (STORE entries) or a directory with the @@ -15,7 +16,9 @@ //! already compressed/wrote, so only new tiles are encoded. Directory saves //! verify existing content-addressed objects once per writer and folder, //! rechecking files whose size or modification time changes; they replace -//! damaged or missing objects and garbage-collect unreferenced ones. +//! damaged or missing objects and garbage-collect unreferenced ones. A directory +//! save holds an OS file lock on `save.lock` throughout, so two writers (or two +//! processes) saving one bundle take turns instead of removing each other's objects. //! //! This crate sits at L3 next to the compositor, so it does not render. //! Callers pass previews in [`SaveOptions`]; `photocraft-io` does that. diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 8cb31f653e..918d7cc639 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -242,6 +242,9 @@ pub struct LayerM { pub advanced: photocraft_doc::AdvancedBlending, #[serde(default)] pub video: Option, + /// The layer's own XMP packet (missing in older files = none). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xmp: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -306,6 +309,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, @@ -324,6 +329,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/read.rs b/crates/format/src/read.rs index 4ebfcbbfa6..8c72a20406 100644 --- a/crates/format/src/read.rs +++ b/crates/format/src/read.rs @@ -23,8 +23,28 @@ pub fn read_file(path: &Path) -> io::Result> { /// [`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> { - let mut f = std::fs::File::open(path)?; - let len = f.metadata().map(|m| m.len()).unwrap_or(0); + #[cfg(unix)] + let (mut f, metadata) = { + use rustix::fs::{Mode, OFlags, open}; + let fd = open(path, OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty()).map_err(io::Error::from)?; + let f = std::fs::File::from(fd); + let metadata = f.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not a regular file")); + } + rustix::fs::fcntl_setfl(&f, OFlags::empty()).map_err(io::Error::from)?; + (f, metadata) + }; + #[cfg(not(unix))] + let (mut f, metadata) = { + let f = std::fs::File::open(path)?; + let metadata = f.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not a regular file")); + } + (f, metadata) + }; + let len = metadata.len(); read_all_with(&mut f, len, chunk) } diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 83e6d3fad0..7b51d438bf 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -18,6 +18,10 @@ use crate::{FormatError, LoadOptions, Result, SaveOptions}; pub(crate) const MANIFEST: &str = "manifest.json"; pub(crate) const THUMB: &str = "thumb.png"; pub(crate) const COMPOSITE: &str = "composite/preview.png"; +/// The file a directory save holds an exclusive lock on for its whole transaction (#4957). +pub(crate) const SAVE_LOCK: &str = "save.lock"; +/// How long a directory save waits for another save of the same bundle to finish. +const SAVE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10); fn tile_path(h: &str) -> String { format!("tiles/{h}.zst") @@ -122,10 +126,17 @@ pub fn read_entry(file: &mut impl std::io::Read, len: u64, max: usize, path: &st /// 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; -/// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] with a linear scan (no recursion), so -/// parsing with serde_json's recursion limit disabled can't exhaust the stack on hostile input. +/// 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; + +/// 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 in_str, mut escaped) = (0usize, false, false); + let (mut depth, mut items, mut in_str, mut escaped) = (0usize, 0usize, false, false); for &b in json { if in_str { match b { @@ -147,6 +158,12 @@ fn check_manifest_depth(json: &[u8]) -> Result<()> { 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"))); + } + } } Ok(()) } @@ -232,17 +249,22 @@ impl Fetch for LoadFetch<'_> { } fn blob(&mut self, hash: &str) -> Result>> { + self.blob_bounded(hash, self.opts.max_blob_bytes) + } + + 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) { 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)?; + let z = self.src.get(&path, limit)?; // 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"))); @@ -390,6 +412,8 @@ pub struct PcraftWriter { compressed_budget: usize, verified_directory: Option, reverify_budget: u64, + /// How long [`Self::save_dir`] waits for another save of the bundle ([`SAVE_LOCK_WAIT`]). + lock_wait: std::time::Duration, } impl Default for PcraftWriter { @@ -401,6 +425,35 @@ impl Default for PcraftWriter { compressed_budget: usize::MAX, verified_directory: None, reverify_budget: DEFAULT_REVERIFY_BUDGET, + lock_wait: SAVE_LOCK_WAIT, + } + } +} + +/// Hold `dir`'s [`SAVE_LOCK`] for a directory save, so two writers (two stores, or two +/// processes) can't interleave: each lists the existing objects, writes its own, publishes its +/// manifest and removes what that manifest doesn't reference. Interleaved, one writer's cleanup +/// could remove an object the other's winning manifest needs (#4957). The lock is the OS's +/// advisory file lock, released when the returned file is dropped or the process ends, so a +/// crash leaves no stale lock. Another save holding it is waited for up to `wait`; after that the +/// save fails without writing anything. A file system without file locks (some network shares) +/// saves unlocked, as before. +fn lock_bundle(dir: &Path, wait: std::time::Duration) -> Result> { + let path = dir.join(SAVE_LOCK); + validate_bundle_file(dir, &path)?; + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path)?; + let start = std::time::Instant::now(); + loop { + match file.try_lock() { + Ok(()) => return Ok(Some(file)), + Err(std::fs::TryLockError::WouldBlock) if start.elapsed() < wait => std::thread::sleep(std::time::Duration::from_millis(20)), + Err(std::fs::TryLockError::WouldBlock) => { + return Err(FormatError::Io(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + format!("another save of {} is in progress; nothing was written", dir.display()), + ))); + } + Err(std::fs::TryLockError::Error(_)) => return Ok(None), } } } @@ -552,14 +605,69 @@ fn validate_bundle_file(root: &Path, path: &Path) -> Result<()> { plain_file_or_missing(path) } -fn write_bundle_file(root: &Path, path: &Path, data: &[u8]) -> Result<()> { +/// Flush `dir`, the folder `written` was just renamed into, as `atomic_write` does (with the +/// full-flush fallback of `sync_file`, #1336). Windows flushes through the injected `AtomicFs` +/// and fails on any error (#7858). +#[cfg(not(windows))] +fn flush_bundle_dir(dir: &Path, written: &Path) -> std::io::Result<()> { + use crate::atomic::AtomicFs; + #[cfg(test)] + { + FLUSHED.with(|f| f.borrow_mut().push(dir.to_path_buf())); + let named = |p: Option<&Path>, name: &str| p.and_then(Path::file_name).is_some_and(|n| n == name); + if FAIL_DIR_FLUSH.with(|f| { + let hit = f.get().is_some_and(|name| named(Some(written), name) || named(written.parent(), name)); + if hit { + f.set(None); + } + hit + }) { + return Err(std::io::Error::other("injected folder flush failure")); + } + } + let _ = written; + crate::atomic::RealFs.sync_dir(dir) +} + +/// [`flush_bundle_dir`], as the save's error when the flush was attempted and failed: `written` +/// is in place, but may not survive a power loss or crash (#4968). +#[cfg(not(windows))] +fn confirm_flushed(dir: &Path, written: &Path) -> Result<()> { + crate::atomic::dir_synced(flush_bundle_dir(dir, written)).map_err(|e| { + FormatError::Io(std::io::Error::new( + e.kind(), + format!("{} was written, but its folder could not be flushed to disk, so the save may not survive a power loss or crash: {e}", written.display()), + )) + }) +} + +#[cfg(all(test, not(windows)))] +thread_local! { + /// Tests: the bundle file, or folder, whose next folder flush fails, once, on this thread. + static FAIL_DIR_FLUSH: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// Tests: the folders flushed on this thread, in order. + static FLUSHED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Publish a validated bundle entry through a flushed temporary file and replacing rename. +/// On Windows, propagate a failed directory barrier even though the new entry is already visible. +fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atomic::AtomicFs) -> Result<()> { + #[cfg(not(windows))] + let _ = fs; validate_bundle_file(root, path)?; let parent = path.parent().ok_or_else(|| unsafe_bundle_entry(path))?; let name = path.file_name().ok_or_else(|| unsafe_bundle_entry(path))?; let mut temporary = None; for _ in 0..8 { let candidate = parent.join(crate::atomic::temp_name(&name.to_string_lossy())); - match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + match opts.open(&candidate) { Ok(file) => { temporary = Some((candidate, file)); break; @@ -589,10 +697,14 @@ fn write_bundle_file(root: &Path, path: &Path, data: &[u8]) -> Result<()> { // itself, never its referent, even if one appears after this check. validate_bundle_file(root, path)?; crate::atomic::retry_rename(crate::atomic::RenameRetry::platform(), || std::fs::rename(&tmp, path))?; - #[cfg(unix)] + #[cfg(windows)] { - let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); + fs.sync_dir(parent).map_err(|e| crate::atomic::publication_error(e, path))?; } + // Elsewhere a failed flush is reported too (#4968), but a file system that can't flush a + // directory at all still saves, as in `atomic_write`. + #[cfg(not(windows))] + confirm_flushed(parent, path)?; Ok(()) })(); if result.is_err() && validate_bundle_file(root, &tmp).is_ok() { @@ -763,6 +875,12 @@ impl PcraftWriter { self.compressed.clear(); } + /// How long a directory save waits for another save of the same bundle to finish before + /// failing without writing anything (10 s by default; see [`Self::save_dir`]). + pub fn set_lock_wait(&mut self, wait: std::time::Duration) { + self.lock_wait = wait; + } + fn prepare(&mut self, doc: &Document, opts: &SaveOptions) -> Result { self.hash_cache.retain(|_, (w, _)| w.strong_count() > 0); self.blob_hash_cache.retain(|_, (w, _)| w.strong_count() > 0); @@ -844,10 +962,18 @@ impl PcraftWriter { /// Save into a directory bundle: reuses only valid existing objects, /// rewrites missing or damaged objects, then writes the manifest - /// atomically and removes unreferenced objects. + /// atomically and removes unreferenced objects. The whole save holds the bundle's + /// `save.lock`, so saves of one bundle from other writers or processes wait their turn. pub fn save_dir(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions) -> Result { + self.save_dir_with(doc, dir, opts, &crate::atomic::RealFs) + } + + /// The same save path with an injectable publication barrier, for failure/GC ordering tests. + fn save_dir_with(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions, fs: &dyn crate::atomic::AtomicFs) -> Result { let canonical_dir = bundle_root(dir)?; let dir = canonical_dir.as_path(); + // Held until the end of the save: listing, writing, publishing and cleanup (#4957). + let _lock = lock_bundle(dir, self.lock_wait)?; let existing = list_objects(dir)?; // Reject a hostile existing entry before writing any object or manifest. This also // covers entries that this save would otherwise leave untouched or garbage-collect. @@ -901,7 +1027,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed())?; + write_bundle_file(dir, &object_path, &obj.compressed(), fs)?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -915,12 +1041,29 @@ impl PcraftWriter { } } for (name, data) in &p.previews { - write_bundle_file(dir, &dir.join(name), data)?; + write_bundle_file(dir, &dir.join(name), data, fs)?; } // Pin all GC directories before removing stale previews or objects. // Unix cleanup then stays within these directories even if a pathname is swapped. let gc = BundleGc::new(dir)?; - write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest)?; + #[cfg(windows)] + { + // A previous attempt may have published an object and then failed its directory + // flush. The retry can reuse those valid bytes (including cached ones), so flush + // both object directories again before publishing a manifest that references them. + // This also persists the fixed subdirectories of a newly created bundle. + for sub in ["tiles", "blobs"] { + fs.sync_dir(&dir.join(sub)).map_err(|e| crate::atomic::publication_error(e, &dir.join(sub)))?; + } + } + #[cfg(not(windows))] + for sub in ["tiles", "blobs"] { + let folder = dir.join(sub); + if folder.is_dir() { + confirm_flushed(&folder, &folder)?; + } + } + 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] { @@ -1082,6 +1225,72 @@ fn gc_pinned_directory_handles_resist_subdirectory_swap() { std::fs::remove_dir_all(root).unwrap(); } +/// #4968: a manifest whose folder can't be flushed fails the save, and the objects the previous +/// manifest references stay in place; the next save that flushes removes what is unreferenced. +#[cfg(all(test, not(windows)))] +#[test] +fn a_failed_manifest_flush_keeps_the_previous_objects() { + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!("pcraft-flush-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let dir = std::fs::canonicalize(dir).unwrap(); + let mut doc = Document::new("flush", photocraft_doc::Size::new(64, 64), photocraft_color::ColorMode::Rgb, SampleType::U8); + let id = doc.insert_above(None, photocraft_doc::Layer::raster("paint", doc.pixel_format())); + let paint = |doc: &mut Document, red: f32| { + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 64), &[red, 0.5, 0.25, 1.0]); + }; + paint(&mut doc, 1.0); + let mut w = PcraftWriter::new(); + w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + let old = list_objects(&dir).unwrap(); + paint(&mut doc, 0.0); + FAIL_DIR_FLUSH.with(|f| f.set(Some(MANIFEST))); + let e = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err(); + assert!(e.to_string().contains("could not be flushed"), "{e}"); + let now = list_objects(&dir).unwrap(); + assert!(old.is_subset(&now), "the previous manifest's objects are kept"); + assert!(now.len() > old.len(), "the new objects were written"); + let stats = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert!(stats.objects_removed > 0, "the next save removes the unreferenced objects"); + assert_eq!(crate::load_path(&dir).unwrap(), doc); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// An object whose folder flush failed is reused by the retried save, which writes no new object +/// there: that save still flushes the object folder before committing a manifest that names it. +#[cfg(all(test, not(windows)))] +#[test] +fn a_retried_save_flushes_the_folder_of_a_reused_object() { + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!("pcraft-retry-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let dir = std::fs::canonicalize(dir).unwrap(); + // One layer of one tile: the retry finds its only object already written. + let mut doc = Document::new("retry", photocraft_doc::Size::new(16, 16), photocraft_color::ColorMode::Rgb, SampleType::U8); + let id = doc.insert_above(None, photocraft_doc::Layer::raster("paint", doc.pixel_format())); + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 16, 16), &[1.0, 0.5, 0.25, 1.0]); + let mut w = PcraftWriter::new(); + FAIL_DIR_FLUSH.with(|f| f.set(Some("tiles"))); + let e = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err(); + assert!(e.to_string().contains("could not be flushed"), "{e}"); + assert!(!dir.join(MANIFEST).exists(), "nothing committed"); + let written = list_objects(&dir).unwrap(); + assert_eq!(written.iter().filter(|o| o.starts_with("tiles/")).count(), 1, "{written:?}"); + FLUSHED.with(|f| f.borrow_mut().clear()); + let stats = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(stats.tiles_written, 0, "the tile is reused"); + let flushed = FLUSHED.with(|f| f.borrow().clone()); + let tiles = flushed.iter().position(|d| *d == dir.join("tiles")); + let manifest = flushed.iter().rposition(|d| *d == dir); + assert!(tiles.is_some() && tiles < manifest, "tiles flushed before the manifest: {flushed:?}"); + assert_eq!(crate::load_path(&dir).unwrap(), doc); + std::fs::remove_dir_all(dir).unwrap(); +} + fn list_objects(dir: &Path) -> Result> { let mut out = HashSet::new(); for sub in ["tiles", "blobs"] { @@ -1118,3 +1327,6 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { crate::atomic::atomic_write(path, data)?; Ok(()) } + +#[cfg(all(test, windows))] +mod durability_tests; diff --git a/crates/format/src/store/durability_tests.rs b/crates/format/src/store/durability_tests.rs new file mode 100644 index 0000000000..477c476a99 --- /dev/null +++ b/crates/format/src/store/durability_tests.rs @@ -0,0 +1,117 @@ +//! Exercise the real directory save, including cached-object reuse and garbage collection. +use std::cell::{Cell, RefCell}; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, Size}; + +use super::{MANIFEST, PcraftWriter, SaveOptions, list_objects}; +use crate::atomic::{AtomicFs, RealFs}; + +/// The object or manifest directory barrier at which to inject one publication failure. +#[derive(Clone, Copy, Debug)] +enum FailAt { + Tiles, + Blobs, + Manifest, +} + +/// Real filesystem operations with recorded barriers and a single failure at the chosen stage. +struct PublicationFault { + root: PathBuf, + old_manifest: Vec, + stage: FailAt, + armed: Cell, + synced: RefCell>, +} + +impl AtomicFs for PublicationFault { + /// Keep temporary-file writes and file flushes real; only directory barriers are injected. + fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> { + RealFs.write_new(path, bytes) + } + /// Publish through the real rename so a barrier error occurs after replacement. + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.rename(from, to) + } + /// Use real cleanup so the test can detect premature removal of old objects. + fn remove(&self, path: &Path) -> io::Result<()> { + RealFs.remove(path) + } + /// Preserve normal replacement metadata behavior during failure injection. + fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_metadata(from, to) + } + /// Record every barrier and fail the selected stage once, allowing a same-writer retry. + fn sync_dir(&self, dir: &Path) -> io::Result<()> { + self.synced.borrow_mut().push(dir.to_path_buf()); + let fail = match self.stage { + FailAt::Tiles => dir == self.root.join("tiles"), + FailAt::Blobs => dir == self.root.join("blobs"), + FailAt::Manifest => dir == self.root && std::fs::read(dir.join(MANIFEST))? != self.old_manifest, + }; + if fail && self.armed.replace(false) { + return Err(io::Error::other("injected publication flush failure")); + } + RealFs.sync_dir(dir) + } +} + +/// Failed object or manifest publication retains old objects; a retry flushes reused objects +/// before the manifest and resumes garbage collection only after durable publication. +#[test] +fn failed_bundle_publication_retains_old_objects_and_same_writer_retry_is_durable() { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + for stage in [FailAt::Tiles, FailAt::Blobs, FailAt::Manifest] { + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pcraft-durability-{}-{id}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let dir = std::fs::canonicalize(dir).unwrap(); + let mut old = Document::new("old", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("pixels", old.pixel_format()); + layer.surface_mut().unwrap().write_pixel(0, 0, &[1.0, 0.0, 0.0, 1.0]); + old.layers.push(layer); + old.metadata.exif = Some(Arc::new(vec![1; 100])); + let mut new = old.clone(); + new.name = "new".into(); + new.layers[0].surface_mut().unwrap().write_pixel(0, 0, &[0.0, 0.0, 1.0, 1.0]); + new.metadata.exif = Some(Arc::new(vec![2; 100])); + + let mut writer = PcraftWriter::new(); + writer.save_dir(&old, &dir, &SaveOptions::default()).unwrap(); + let old_objects: Vec<_> = list_objects(&dir).unwrap().into_iter().map(|p| (p.clone(), std::fs::read(dir.join(p)).unwrap())).collect(); + assert!(old_objects.len() >= 2); + let fs = PublicationFault { + old_manifest: std::fs::read(dir.join(MANIFEST)).unwrap(), + root: dir.clone(), + stage, + armed: Cell::new(true), + synced: RefCell::new(Vec::new()), + }; + + let error = writer.save_dir_with(&new, &dir, &SaveOptions::default(), &fs).unwrap_err(); + assert!(!fs.armed.get(), "the {stage:?} failure fired"); + assert!(error.to_string().contains("may not survive"), "{error}"); + let expected = if matches!(stage, FailAt::Manifest) { &new } else { &old }; + assert_eq!(&crate::load_path(&dir).unwrap(), expected); + for (path, bytes) in &old_objects { + assert_eq!(&std::fs::read(dir.join(path)).unwrap(), bytes, "old object removed before durable publication: {path}"); + } + + fs.synced.borrow_mut().clear(); + writer.save_dir_with(&new, &dir, &SaveOptions::default(), &fs).unwrap(); + assert_eq!(crate::load_path(&dir).unwrap(), new); + let synced = fs.synced.borrow(); + let publication = synced.iter().rposition(|p| p == &dir).unwrap(); + for sub in ["tiles", "blobs"] { + assert!( + synced.iter().position(|p| p == &dir.join(sub)).is_some_and(|position| position < publication), + "retry must flush reused {sub} before its manifest" + ); + } + assert!(old_objects.iter().all(|(p, _)| !dir.join(p).exists()), "cleanup resumes after the durable retry"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/format/src/zip.rs b/crates/format/src/zip.rs index 9ec92c2fba..5057e52168 100644 --- a/crates/format/src/zip.rs +++ b/crates/format/src/zip.rs @@ -127,11 +127,24 @@ impl<'a> ZipReader<'a> { if bytes.len() < 22 { return Err(FormatError::corrupt("not a zip archive (too short)")); } - // End of central directory: scan back over a possible comment. + // A comment may itself contain the signature. Only accept a record whose + // declared comment reaches the end of the archive (APPNOTE 4.3.16). let min = bytes.len().saturating_sub(22 + 65535); let eocd = (min..=bytes.len() - 22) .rev() - .find(|&i| u32_at(bytes, i).ok() == Some(EOCD_SIG)) + .find(|&i| { + if u32_at(bytes, i).ok() != Some(EOCD_SIG) { + return false; + } + let comment_end = u16_at(bytes, i + 20).ok().and_then(|length| i.checked_add(22)?.checked_add(usize::from(length))); + let directory_end = u32_at(bytes, i + 16) + .ok() + .zip(u32_at(bytes, i + 12).ok()) + .and_then(|(offset, size)| usize::try_from(offset).ok()?.checked_add(usize::try_from(size).ok()?)); + // In the supported non-ZIP64 layout, the central directory immediately + // precedes this record. A zeroed signature in a comment is not an empty ZIP. + comment_end == Some(bytes.len()) && directory_end == Some(i) + }) .ok_or_else(|| FormatError::corrupt("zip end-of-central-directory not found"))?; let count = u16_at(bytes, eocd + 10)? as usize; let cd_size = u32_at(bytes, eocd + 12)? as usize; @@ -263,6 +276,56 @@ mod tests { assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_)))); } + fn archive_with_comment(comment: &[u8]) -> Vec { + let mut writer = ZipWriter::new(); + writer.add("manifest.json", b"project content").unwrap(); + let mut bytes = writer.finish().unwrap(); + let length_offset = bytes.len() - 2; + bytes[length_offset..].copy_from_slice(&u16::try_from(comment.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(comment); + bytes + } + + #[test] + fn directory_signature_inside_comment_does_not_hide_entries() { + for length in [22, 32, usize::from(u16::MAX)] { + let mut comment = vec![0; length]; + comment[..4].copy_from_slice(&EOCD_SIG.to_le_bytes()); + let bytes = archive_with_comment(&comment); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.entries.len(), 1, "comment length {length}"); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content"); + } + } + + #[test] + fn archive_comments_support_empty_short_and_maximum_lengths() { + for length in [0, 1, 21, 22, 100, usize::from(u16::MAX)] { + let bytes = archive_with_comment(&vec![b'x'; length]); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content", "comment length {length}"); + } + } + + #[test] + fn central_directory_digital_signature_remains_supported() { + let mut bytes = archive_with_comment(b"signed archive"); + let eocd = bytes.len() - 22 - b"signed archive".len(); + let signature = [0x50, 0x4b, 0x05, 0x05, 3, 0, 1, 2, 3]; + let size = u32_at(&bytes, eocd + 12).unwrap() + signature.len() as u32; + bytes[eocd + 12..eocd + 16].copy_from_slice(&size.to_le_bytes()); + bytes.splice(eocd..eocd, signature); + let reader = ZipReader::new(&bytes).unwrap(); + assert_eq!(reader.read_by_name("manifest.json", 100).unwrap(), b"project content"); + } + + #[test] + fn truncated_archive_comment_is_rejected() { + let mut bytes = archive_with_comment(b"comment"); + bytes.pop(); + assert!(ZipReader::new(&bytes).is_err()); + } + #[test] fn crc_matches_bitwise_reference() { // Bit-at-a-time IEEE CRC-32 as the oracle, over lengths that cross diff --git a/crates/format/tests/analysis.rs b/crates/format/tests/it/analysis.rs similarity index 100% rename from crates/format/tests/analysis.rs rename to crates/format/tests/it/analysis.rs diff --git a/crates/format/tests/autosave.rs b/crates/format/tests/it/autosave.rs similarity index 99% rename from crates/format/tests/autosave.rs rename to crates/format/tests/it/autosave.rs index a1954682a1..24d61d50d3 100644 --- a/crates/format/tests/autosave.rs +++ b/crates/format/tests/it/autosave.rs @@ -1,6 +1,6 @@ //! Background autosave and crash recovery. -mod common; +use crate::common; use std::sync::Arc; use common::*; diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/it/common/mod.rs similarity index 97% rename from crates/format/tests/common/mod.rs rename to crates/format/tests/it/common/mod.rs index 0c91e33e32..ce0381ff7c 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/it/common/mod.rs @@ -66,7 +66,12 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { mask.linked = false; paint.mask = Some(mask); paint.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 0.8), color: Color::rgb(1.0, 0.2, 0.1) }); - paint.effects.items.push(Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 0.8), gradient: Gradient::default(), dither: true }); + paint.effects.items.push(Effect::GradientOverlay { + common: FxCommon::new(BlendMode::Normal, 0.8), + gradient: Gradient::default(), + dither: true, + interpolation: None, + }); paint.effects.psd_raw = Some(blob(3, 64)); paint.psd_blocks.push((*b"vmsk", blob(4, 40))); paint.psd_blocks.push((*b"lnsr", blob(5, 4))); @@ -169,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 { @@ -198,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![], @@ -228,7 +235,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { d.color_table = Some(photocraft_doc::ColorTable { colors: vec![[0, 0, 0], [255, 128, 0]], transparent: Some(1) }); d.duotone = Some(photocraft_doc::Duotone { inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3]), photocraft_doc::DuotoneInk::new("PANTONE 151 C", [1.0, 0.5, 0.0])], - psd_raw: Some(vec![1, 2, 3]), + psd_raw: Some(vec![1, 2, 3].into()), }); d.guides = Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25], colors: vec![GuideColor { vertical: true, position: 100.25, rgb: [0x33, 0x66, 0xff] }] }; diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/it/corruption.rs similarity index 96% rename from crates/format/tests/corruption.rs rename to crates/format/tests/it/corruption.rs index 727b37cb70..1a69a60055 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/it/corruption.rs @@ -1,6 +1,6 @@ //! Damaged or hostile bundles must fail cleanly, never panic. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_format::*; @@ -128,6 +128,24 @@ fn over_deep_manifest_fails_cleanly() { assert!(matches!(e, FormatError::Corrupt(_)), "{e}"); } +/// A manifest of millions of tiny values (`[0,0,…]` deflates about 1000:1) is refused before +/// it is parsed into a JSON tree, which would cost about 16 bytes of memory per input byte. +#[test] +fn manifest_with_too_many_values_is_refused() { + let n = (4 << 20) + 16; + let mut m = Vec::with_capacity(2 * n + 1); + m.push(b'['); + for i in 0..n { + m.extend_from_slice(if i == 0 { b"0" } else { b",0" }); + } + m.push(b']'); + let b = rebuild(&sample(), |name, d| Some(if name == "manifest.json" { m.clone() } else { d })); + let e = load_from_bytes(&b).unwrap_err(); + assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}"); + let e = read_manifest(&b).unwrap_err(); + assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}"); +} + #[test] fn too_new_version_rejected() { let b = rebuild(&sample(), |n, d| { diff --git a/crates/format/tests/directory_symlinks.rs b/crates/format/tests/it/directory_symlinks.rs similarity index 99% rename from crates/format/tests/directory_symlinks.rs rename to crates/format/tests/it/directory_symlinks.rs index edeebbdc3d..0a8d7d2462 100644 --- a/crates/format/tests/directory_symlinks.rs +++ b/crates/format/tests/it/directory_symlinks.rs @@ -1,6 +1,6 @@ //! Directory cleanup only owns content-addressed objects; unrelated files survive. -mod common; +use crate::common; use common::temp_dir; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/format/tests/float_roundtrip.rs b/crates/format/tests/it/float_roundtrip.rs similarity index 100% rename from crates/format/tests/float_roundtrip.rs rename to crates/format/tests/it/float_roundtrip.rs diff --git a/crates/format/tests/layer_labels.rs b/crates/format/tests/it/layer_labels.rs similarity index 100% rename from crates/format/tests/layer_labels.rs rename to crates/format/tests/it/layer_labels.rs diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs new file mode 100644 index 0000000000..6c822f4282 --- /dev/null +++ b/crates/format/tests/it/main.rs @@ -0,0 +1,11 @@ +//! The crate's integration tests, linked into one test binary. + +mod analysis; +mod autosave; +mod common; +mod corruption; +mod directory_symlinks; +mod float_roundtrip; +mod layer_labels; +mod roundtrip; +mod smart_contents; diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/it/roundtrip.rs similarity index 89% rename from crates/format/tests/roundtrip.rs rename to crates/format/tests/it/roundtrip.rs index 725bc2c070..06d3928fd1 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/it/roundtrip.rs @@ -1,6 +1,6 @@ //! doc → .pcraft → doc equality, incremental saves, ids, previews. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun}; @@ -11,6 +11,46 @@ use photocraft_raster::Rgba8Image; const MODES: [ColorMode; 4] = [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab]; +#[test] +fn gradient_overlay_interpolation_roundtrips_in_native_documents() { + use photocraft_doc::effects::GradientInterpolation; + use photocraft_doc::{Color, Effect, FxCommon, Gradient}; + + for mode in MODES { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = rich_doc(mode, depth); + let source = GradientInterpolation { + method: "smooth".into(), + smoothness: 73.0, + stops: vec![(0.0, Color::rgb(0.123456, 0.4, 0.9)), (0.4, Color::WHITE), (1.0, Color::BLACK)], + }; + let effect = Effect::GradientOverlay { + common: FxCommon::new(photocraft_doc::BlendMode::Normal, 1.0), + gradient: Gradient::default(), + dither: true, + interpolation: Some(source), + } + .in_mode(mode); + doc.layers.first_mut().unwrap().effects.items.push(effect); + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(load_from_bytes(&bytes).unwrap(), doc); + } + } +} + +#[test] +fn old_effect_snapshots_need_no_interpolation_record() { + let effect = photocraft_doc::Effect::GradientOverlay { + common: photocraft_doc::FxCommon::new(photocraft_doc::BlendMode::Normal, 1.0), + gradient: photocraft_doc::Gradient::default(), + dither: false, + interpolation: None, + }; + let json = serde_json::to_value(&effect).unwrap(); + assert!(json["GradientOverlay"].get("interpolation").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), effect); +} + #[test] fn custom_shape_stroke_roundtrip_keeps_every_style_field() { for mode in MODES { @@ -599,6 +639,32 @@ fn path_text_shape_roundtrips() { assert_eq!(shape, Some(TextShape::Path { path, start: 12.5, flip: true })); } +/// Area type (#218): the whole path, ops and fill rule included, survives .pcraft. +#[test] +fn area_text_shape_roundtrips() { + use photocraft_doc::text::TextShape; + use photocraft_doc::vector::{FillRule, Path, PathOp, Subpath}; + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let mut path = Path::new(vec![ + Subpath::polygon(&[(0.0, 0.0), (200.0, 0.0), (200.0, 120.0), (0.0, 120.0)]), + Subpath::polygon(&[(80.0, 40.0), (120.0, 40.0), (120.0, 80.0)]).with_op(PathOp::Subtract), + ]); + path.fill_rule = FillRule::EvenOdd; + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + assert!(!ids.is_empty()); + for id in ids { + if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { + t.shape = TextShape::Area { path: path.clone() }; + } + } + let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let shape = back.walk().into_iter().find_map(|(_, _, l)| match &l.content { + LayerContent::Text(t) => Some(t.shape.clone()), + _ => None, + }); + assert_eq!(shape, Some(TextShape::Area { path })); +} + #[test] fn channel_restrictions_and_bevel_elements_roundtrip() { use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect}; @@ -768,6 +834,34 @@ fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() assert_eq!(back, doc); } +/// #4957: a directory save holds the bundle's lock for its whole transaction. While another +/// writer holds it, a save waits, then fails without writing anything; once it is released the +/// save goes ahead. +#[test] +fn directory_save_waits_for_the_bundle_lock() { + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let dir = temp_dir("lock"); + let other = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join("save.lock")).unwrap(); + // A file system without file locks saves unlocked (`lock_bundle`), so there's nothing to test. + match other.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::Error(e)) if e.kind() == std::io::ErrorKind::Unsupported => return, + Err(std::fs::TryLockError::Error(e)) => panic!("locking save.lock failed: {e}"), + Err(std::fs::TryLockError::WouldBlock) => panic!("save.lock is already locked"), + } + let mut w = PcraftWriter::new(); + w.set_lock_wait(std::time::Duration::from_millis(100)); + let e = w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap_err(); + assert!(e.to_string().contains("in progress"), "{e}"); + assert!(!dir.join("manifest.json").exists(), "nothing written while locked"); + assert!(!dir.join("tiles").exists() || std::fs::read_dir(dir.join("tiles")).unwrap().count() == 0); + other.unlock().unwrap(); + w.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); + assert_eq!(load_path(&dir).unwrap(), doc); + drop(other); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn variables_follow_layer_id_remapping() { let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); diff --git a/crates/format/tests/smart_contents.rs b/crates/format/tests/it/smart_contents.rs similarity index 69% rename from crates/format/tests/smart_contents.rs rename to crates/format/tests/it/smart_contents.rs index ce91f85710..3202214014 100644 --- a/crates/format/tests/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/geom/src/lib.rs b/crates/geom/src/lib.rs index d95e4b847f..44a078dd36 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -96,6 +96,14 @@ impl Rect { pub fn inflate(&self, d: i32) -> Rect { Rect::new(self.x0.saturating_sub(d), self.y0.saturating_sub(d), self.x1.saturating_add(d), self.y1.saturating_add(d)) } + /// How many tiles overlap this rect, without visiting them (a rect can span billions). + pub fn tile_count(&self) -> u64 { + if self.is_empty() { + return 0; + } + let (a, b) = (TileCoord::containing(self.x0, self.y0), TileCoord::containing(self.x1 - 1, self.y1 - 1)); + ((i64::from(b.tx) - i64::from(a.tx) + 1) as u64) * ((i64::from(b.ty) - i64::from(a.ty) + 1) as u64) + } /// All tiles overlapping this rect. pub fn tiles(&self) -> impl Iterator + use<> { let (a, b) = if self.is_empty() { @@ -181,14 +189,16 @@ impl Affine { pub fn determinant(&self) -> f64 { self.m[0] * self.m[3] - self.m[1] * self.m[2] } + /// Returns `None` for a near-singular matrix or non-finite determinant/inverse coefficients. pub fn inverse(&self) -> Option { let det = self.determinant(); - if det.abs() < 1e-12 { + if !det.is_finite() || det.abs() < 1e-12 { return None; } let [a, b, c, d, e, f] = self.m; let inv = 1.0 / det; - Some(Affine { m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv] }) + let inverse = Affine { m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv] }; + inverse.m.iter().all(|v| v.is_finite()).then_some(inverse) } } @@ -245,6 +255,34 @@ mod tests { assert!(Affine::scale(0.0).inverse().is_none()); } + #[test] + fn affine_inverse_rejects_non_finite_coefficients() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for index in 0..6 { + let mut transform = Affine::IDENTITY; + transform.m[index] = value; + assert!(transform.inverse().is_none(), "coefficient {index}: {value}"); + } + } + } + + #[test] + fn affine_inverse_rejects_determinant_overflow() { + assert!(Affine::scale(f64::MAX).inverse().is_none()); + } + + #[test] + fn affine_inverse_rejects_translation_overflow() { + let transform = Affine { m: [1.0, 0.0, 1.0, 1.0, -f64::MAX, f64::MAX] }; + assert_eq!(transform.determinant(), 1.0); + assert!(transform.inverse().is_none()); + } + + #[test] + fn affine_inverse_keeps_large_finite_translations() { + assert_eq!(Affine::translate(f64::MAX, -f64::MAX).inverse(), Some(Affine::translate(-f64::MAX, f64::MAX))); + } + #[test] fn affine_then_order() { let t = Affine::translate(10.0, 0.0).then(&Affine::scale(2.0)); diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 9c8f138747..cbbd746195 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -9,6 +9,9 @@ //! - **Custom meshes** ([`BezierMesh`]): a grid of bicubic Bezier patches. The default is one //! patch (a 4×4 control grid); split warps add patch rows/columns by exact de Casteljau //! subdivision, so splitting never changes the shape. +//! - **Cylinder** ([`WarpStyle::Cylinder`], Transform Warp only): seven `warpValues` that +//! Photoshop turns into a 4 × 1 patch mesh wrapping the contents round a cylinder; see +//! [`cylinder_mesh`]. //! //! Pure geometry: resampling lives in `photocraft-algo::warp`. @@ -38,6 +41,10 @@ pub enum WarpStyle { Inflate, Squeeze, Twist, + /// Edit › Transform › Warp's Cylinder (`warpCylinder`): not one of the fifteen bend presets + /// (Warp Text doesn't have it), it takes seven values instead of a bend; see + /// [`Warp::cylinder`]. + Cylinder, } impl WarpStyle { @@ -60,7 +67,8 @@ impl WarpStyle { WarpStyle::Twist, ]; - /// Every style (none, custom, presets). + /// The styles the Warp menus list: none, custom and the presets. [`WarpStyle::Cylinder`] is + /// read from PSDs and taken by commands, but has no editor yet. pub fn all() -> impl Iterator { [WarpStyle::None, WarpStyle::Custom].into_iter().chain(Self::PRESETS) } @@ -85,6 +93,7 @@ impl WarpStyle { WarpStyle::Inflate => "warpInflate", WarpStyle::Squeeze => "warpSqueeze", WarpStyle::Twist => "warpTwist", + WarpStyle::Cylinder => "warpCylinder", } } @@ -109,6 +118,7 @@ impl WarpStyle { "Fisheye" => "fisheye", "Inflate" => "inflate", "Squeeze" => "squeeze", + "Cylinder" => "cylinder", _ => "twist", } } @@ -133,17 +143,19 @@ impl WarpStyle { WarpStyle::Inflate => "Inflate", WarpStyle::Squeeze => "Squeeze", WarpStyle::Twist => "Twist", + WarpStyle::Cylinder => "Cylinder", } } /// Parses a short id, a PSD name or a label (case-insensitive, spaces ignored). pub fn parse(s: &str) -> Option { let k: String = s.chars().filter(|c| !c.is_whitespace()).collect::().to_ascii_lowercase(); - Self::all().find(|w| w.id().to_ascii_lowercase() == k || w.psd_name().to_ascii_lowercase() == k) + Self::all().chain([WarpStyle::Cylinder]).find(|w| w.id().to_ascii_lowercase() == k || w.psd_name().to_ascii_lowercase() == k) } + /// One of the fifteen bend presets ([`Self::PRESETS`]). pub fn is_preset(self) -> bool { - !matches!(self, WarpStyle::None | WarpStyle::Custom) + Self::PRESETS.contains(&self) } } @@ -357,7 +369,7 @@ fn preset_grid(style: WarpStyle, b: f64, hd: f64, vd: f64, vertical: bool, aspec g[2][2][1] -= a; } } - WarpStyle::None | WarpStyle::Custom => {} + WarpStyle::None | WarpStyle::Custom | WarpStyle::Cylinder => {} } // Vertical distortion: the top and bottom rows scale about their end points' midpoint (by // 1 ∓ vd) and the middle rows follow by the blend of those two moves. @@ -412,6 +424,101 @@ fn split_cubic(p: [[f64; 2]; 4], t: f64) -> [[f64; 2]; 7] { [p[0], a, d, f, e, c, p[3]] } +/// Photoshop's Cylinder warp as a Bezier mesh in the `warp` descriptor's value space (the space +/// of its `bounds`), from the seven `warpValues` `v` of a horizontal cylinder with no +/// perspective. Like a custom warp's mesh, its control-point box is what the placement quad +/// frames. `None` unless there are seven finite values with the right silhouette right of the +/// left one. +/// +/// The model was measured against Photoshop 2026's renders of a UV grid (issue #4112: within +/// 0.3 px vertically and 1.2 px horizontally at the 99th percentile), not taken from Adobe's code: +/// - `v[0]`, `v[2]`: the left and right silhouettes. Half axis `a = (v[2] − v[0]) / 2`. +/// - `v[3]`, `v[1]`: the top and bottom edges at the silhouettes (the centre lines of the upper +/// and lower ellipses); `v[4]`, `v[5]`: those ellipses' depth in units of `a`. +/// - `v[6]` (0..1): how far the contents wrap round. The outer `uL` of the contents on each side +/// is on the back of the cylinder and collapses onto the silhouette: 1/6 at 0, 0.006 at 1. +/// +/// Four patches across (`us = [0, uL, 0.5, 1 − uL, 1]`: back, front left, front right, back) and +/// one down; control rows are linear between the top and bottom edge. +pub fn cylinder_mesh(v: &[f64]) -> Option { + let v: [f64; 7] = v.try_into().ok()?; + if !v.iter().all(|x| x.is_finite()) || v[2] <= v[0] { + return None; + } + let a = (v[2] - v[0]) / 2.0; + let cx = (v[0] + v[2]) / 2.0; + let [ul, kx, ky, my] = cylinder_shape(v[6]); + let s = [-1.0, -1.0, -1.0, -1.0, -1.0, -kx, 0.0, kx, 1.0, 1.0, 1.0, 1.0, 1.0]; + let e = [0.0, 0.0, 0.0, 0.0, ky, my, 1.0, my, ky, 0.0, 0.0, 0.0, 0.0]; + let mut points = Vec::with_capacity(13 * 4); + for r in 0..4 { + let t = f64::from(r) / 3.0; + for (sk, ek) in s.iter().zip(e) { + let (top, bottom) = (v[3] + v[4] * a * ek, v[1] + v[5] * a * ek); + points.push([cx + a * sk, top + (bottom - top) * t]); + } + } + let mesh = BezierMesh { us: vec![0.0, ul, 0.5, 1.0 - ul, 1.0], vs: vec![0.0, 1.0], points }; + mesh.is_valid().then_some(mesh) +} + +/// `[uL, kx, ky, my]` for a cylinder's wrap `g` (`warpValues[6]`, clamped to 0..1): the share of +/// the contents on the back on each side, then the front patches' inner x control point (in +/// units of the half axis) and their ellipse factors. Linear between the measured rows. +fn cylinder_shape(g: f64) -> [f64; 4] { + let n = CYLINDER_SHAPE.len() - 1; + let x = if g.is_finite() { g.clamp(0.0, 1.0) * n as f64 } else { 0.0 }; + let i = (x.floor() as usize).min(n - 1); + let (a, b, t) = (CYLINDER_SHAPE[i], CYLINDER_SHAPE[i + 1], x - i as f64); + std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t) +} + +/// [`cylinder_shape`]'s rows for `g` = 0, 0.025 … 1, measured from Photoshop's renders (#4112): +/// no closed form was found. +const CYLINDER_SHAPE: [[f64; 4]; 41] = [ + [0.166169, 0.655404, 0.382374, 1.003104], + [0.157304, 0.650228, 0.388326, 1.002685], + [0.148438, 0.645374, 0.394211, 1.007918], + [0.140060, 0.639652, 0.401415, 1.006938], + [0.131885, 0.634064, 0.408465, 1.006069], + [0.123875, 0.628845, 0.414586, 1.005743], + [0.116162, 0.623565, 0.421033, 1.005233], + [0.108674, 0.618563, 0.426905, 1.004990], + [0.101198, 0.614439, 0.431943, 1.010787], + [0.094322, 0.609611, 0.437749, 1.010560], + [0.087757, 0.604929, 0.443406, 1.010361], + [0.081514, 0.600390, 0.448907, 1.010190], + [0.075599, 0.596012, 0.454288, 1.010019], + [0.069994, 0.591836, 0.459420, 1.009888], + [0.064709, 0.587844, 0.464361, 1.009768], + [0.059740, 0.584026, 0.469109, 1.009669], + [0.055072, 0.580427, 0.473609, 1.009588], + [0.050712, 0.576990, 0.477904, 1.009528], + [0.046635, 0.573760, 0.481968, 1.009477], + [0.042824, 0.570758, 0.485764, 1.009440], + [0.039295, 0.567910, 0.489377, 1.009411], + [0.036023, 0.565229, 0.492800, 1.009382], + [0.032681, 0.563478, 0.495519, 1.015285], + [0.029877, 0.561152, 0.498513, 1.015270], + [0.027282, 0.559012, 0.501313, 1.015245], + [0.024894, 0.557004, 0.503913, 1.015241], + [0.022693, 0.555163, 0.506331, 1.015225], + [0.020668, 0.553463, 0.508575, 1.015213], + [0.018816, 0.551877, 0.510651, 1.015217], + [0.017106, 0.550434, 0.512556, 1.015214], + [0.015542, 0.549100, 0.514319, 1.015213], + [0.014115, 0.547868, 0.515936, 1.015223], + [0.012806, 0.546743, 0.517432, 1.015223], + [0.011600, 0.545730, 0.518775, 1.015228], + [0.010506, 0.544788, 0.520034, 1.015230], + [0.009510, 0.543920, 0.521179, 1.015238], + [0.008600, 0.543134, 0.522224, 1.015244], + [0.007763, 0.542420, 0.523173, 1.015251], + [0.007006, 0.541757, 0.524065, 1.015252], + [0.006315, 0.541164, 0.524867, 1.015252], + [0.005686, 0.540608, 0.525602, 1.015261], +]; + /// Inverse of the Bernstein interpolation matrix at t = 0, 1/3, 2/3, 1 (rows: control point, /// columns: sample). Exact rational values. const BERN_INV: [[f64; 4]; 4] = [[1.0, 0.0, 0.0, 0.0], [-5.0 / 6.0, 3.0, -1.5, 1.0 / 3.0], [1.0 / 3.0, -1.5, 3.0, -5.0 / 6.0], [0.0, 0.0, 0.0, 1.0]]; @@ -474,38 +581,50 @@ impl BezierMesh { /// Control points per row. pub fn nx(&self) -> usize { - 3 * (self.us.len() - 1) + 1 + 3usize.saturating_mul(self.us.len().saturating_sub(1)).saturating_add(1) } /// Control point rows. pub fn ny(&self) -> usize { - 3 * (self.vs.len() - 1) + 1 + 3usize.saturating_mul(self.vs.len().saturating_sub(1)).saturating_add(1) } pub fn point(&self, i: usize, j: usize) -> [f64; 2] { - self.points[j * self.nx() + i] + self.points.get(j.saturating_mul(self.nx()).saturating_add(i)).copied().unwrap_or([0.0; 2]) } /// Checks the invariants (knots and point count); malformed data from files is rejected. pub fn is_valid(&self) -> bool { - let knots_ok = |k: &[f64]| k.len() >= 2 && k[0] == 0.0 && k[k.len() - 1] == 1.0 && k.windows(2).all(|w| w[1] > w[0]); - knots_ok(&self.us) - && knots_ok(&self.vs) - && self.points.len() == self.nx() * self.ny() - && self.points.iter().all(|p| p[0].is_finite() && p[1].is_finite()) + let knots_ok = |k: &[f64]| (2..=65).contains(&k.len()) && k[0] == 0.0 && k[k.len() - 1] == 1.0 && k.windows(2).all(|w| w[1] > w[0]); + if !knots_ok(&self.us) || !knots_ok(&self.vs) { + return false; + } + let Some(nx) = (self.us.len() - 1).checked_mul(3).and_then(|v| v.checked_add(1)) else { + return false; + }; + let Some(ny) = (self.vs.len() - 1).checked_mul(3).and_then(|v| v.checked_add(1)) else { + return false; + }; + let Some(expected) = nx.checked_mul(ny) else { + return false; + }; + self.points.len() == expected && self.points.iter().all(|p| p[0].is_finite() && p[1].is_finite()) } fn locate(knots: &[f64], s: f64) -> (usize, f64) { - let n = knots.len() - 1; - let s = s.clamp(0.0, 1.0); - let mut i = 0; - while i + 1 < n && s > knots[i + 1] { - i += 1; + if knots.len() < 2 { + return (0, 0.0); } + let n = knots.len() - 1; + let s = if s.is_finite() { s.clamp(0.0, 1.0) } else { 0.0 }; + let i = knots[1..n].partition_point(|&k| k < s); let w = knots[i + 1] - knots[i]; (i, if w > 0.0 { (s - knots[i]) / w } else { 0.0 }) } /// Evaluates the surface at normalised `(s, t)`. pub fn eval(&self, s: f64, t: f64) -> [f64; 2] { + if self.us.len() < 2 || self.vs.len() < 2 { + return [0.0; 2]; + } let (pi, a) = Self::locate(&self.us, s); let (pj, b) = Self::locate(&self.vs, t); let (ba, bb) = (bernstein(a), bernstein(b)); @@ -513,7 +632,10 @@ impl BezierMesh { let mut out = [0.0; 2]; for (j, wb) in bb.iter().enumerate() { for (i, wa) in ba.iter().enumerate() { - let p = self.points[(3 * pj + j) * nx + 3 * pi + i]; + let idx = (3 * pj + j).saturating_mul(nx).saturating_add(3 * pi + i); + let Some(&p) = self.points.get(idx) else { + return [0.0; 2]; + }; let k = wa * wb; out[0] += p[0] * k; out[1] += p[1] * k; @@ -555,7 +677,7 @@ impl BezierMesh { /// Exact when the patches came from an unedited split; otherwise the outer control points are /// kept and the inner handles extrapolated. pub fn remove_split_u(&mut self, k: usize) -> bool { - if k == 0 || k + 1 >= self.us.len() { + if k == 0 || k >= self.us.len().saturating_sub(1) { return false; } let a = (self.us[k] - self.us[k - 1]) / (self.us[k + 1] - self.us[k - 1]); @@ -917,15 +1039,18 @@ pub struct Warp { pub vertical: bool, /// The source box `[x0, y0, x1, y1]` the warp is defined over. pub bounds: [f64; 4], - /// Control mesh for [`WarpStyle::Custom`] (output coordinates). + /// Control mesh for [`WarpStyle::Custom`] and [`WarpStyle::Cylinder`] (output coordinates). #[serde(default)] pub mesh: Option, + /// A cylinder's seven `warpValues` (see [`Warp::cylinder`]); empty for other styles. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub values: Vec, } impl Warp { /// No warp over `bounds`. pub fn none(bounds: [f64; 4]) -> Warp { - Warp { style: WarpStyle::None, bend: 0.0, h_distort: 0.0, v_distort: 0.0, vertical: false, bounds, mesh: None } + Warp { style: WarpStyle::None, bend: 0.0, h_distort: 0.0, v_distort: 0.0, vertical: false, bounds, mesh: None, values: Vec::new() } } /// A preset style. @@ -938,6 +1063,13 @@ impl Warp { Warp { style: WarpStyle::Custom, mesh: Some(mesh), ..Warp::none(bounds) } } + /// A horizontal Cylinder warp over `bounds` from its seven `warpValues` (see + /// [`cylinder_mesh`]). `None` when they don't describe one. + pub fn cylinder(values: &[f64], bounds: [f64; 4]) -> Option { + let mesh = cylinder_mesh(values)?; + Some(Warp { style: WarpStyle::Cylinder, mesh: Some(mesh), values: values.to_vec(), ..Warp::none(bounds) }) + } + fn style_warp(&self) -> Option { StyleWarp::new(self.style, self.bend, self.h_distort, self.v_distort, self.vertical, self.bounds) } @@ -946,11 +1078,32 @@ impl Warp { pub fn is_identity(&self) -> bool { match self.style { WarpStyle::None => true, - WarpStyle::Custom => match &self.mesh { + WarpStyle::Custom | WarpStyle::Cylinder => match &self.mesh { None => true, Some(m) => { - let id = BezierMesh::fit(&|s, t| self.box_point(s, t), m.us.clone(), m.vs.clone()); - m.points.iter().zip(&id.points).all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9) + if !m.is_valid() { + return false; + } + let nx = m.nx(); + let (pc, pr) = (m.us.len() - 1, m.vs.len() - 1); + for pj in 0..pr { + for j in 0..4 { + let v = m.vs[pj] + (m.vs[pj + 1] - m.vs[pj]) * j as f64 / 3.0; + for pi in 0..pc { + for i in 0..4 { + let u = m.us[pi] + (m.us[pi + 1] - m.us[pi]) * i as f64 / 3.0; + let expected = self.box_point(u, v); + let actual = m.points[(3 * pj + j) * nx + 3 * pi + i]; + // Relative to the magnitude so the fit's rounding at large origins isn't rejected. + let tol = |e: f64| (1e-9 * e.abs().max(1.0)).min(1e-6); + if (actual[0] - expected[0]).abs() >= tol(expected[0]) || (actual[1] - expected[1]).abs() >= tol(expected[1]) { + return false; + } + } + } + } + } + true } }, _ => self.style_warp().is_none(), @@ -966,7 +1119,7 @@ impl Warp { pub fn map(&self, x: f64, y: f64) -> (f64, f64) { match self.style { WarpStyle::None => (x, y), - WarpStyle::Custom => match &self.mesh { + WarpStyle::Custom | WarpStyle::Cylinder => match &self.mesh { Some(m) => { let b = self.bounds; let (w, h) = (b[2] - b[0], b[3] - b[1]); @@ -986,9 +1139,9 @@ impl Warp { } /// The warp as a control mesh (a preset is its own Bezier patch split into `cols × rows`; - /// custom warps return their own mesh). + /// custom and cylinder warps return their own mesh). pub fn to_mesh(&self, cols: usize, rows: usize) -> BezierMesh { - if let (WarpStyle::Custom, Some(m)) = (self.style, &self.mesh) { + if let (WarpStyle::Custom | WarpStyle::Cylinder, Some(m)) = (self.style, &self.mesh) { return m.clone(); } let (cols, rows) = (cols.clamp(1, 64), rows.clamp(1, 64)); @@ -1275,6 +1428,26 @@ mod tests { } } + #[test] + fn remove_split_u_rejects_out_of_range_indices() { + let original = BezierMesh::identity(B, 2, 3); + for k in [0, original.us.len() - 1, original.us.len(), usize::MAX - 1, usize::MAX] { + let mut mesh = original.clone(); + assert!(!mesh.remove_split_u(k), "index {k}"); + assert_eq!(mesh, original, "index {k} must leave the mesh unchanged"); + } + } + + #[test] + fn remove_split_v_rejects_out_of_range_indices() { + let original = BezierMesh::identity(B, 2, 3); + for k in [0, original.vs.len() - 1, original.vs.len(), usize::MAX - 1, usize::MAX] { + let mut mesh = original.clone(); + assert!(!mesh.remove_split_v(k), "index {k}"); + assert_eq!(mesh, original, "index {k} must leave the mesh unchanged"); + } + } + #[test] fn splits_are_exact_and_removable() { let mut m = BezierMesh::identity(B, 1, 1); @@ -1364,4 +1537,127 @@ mod tests { let ob = Warp::none(B).output_bounds(); assert!((ob[0] - 10.0).abs() < 1e-9 && (ob[3] - 70.0).abs() < 1e-9); } + + /// A cylinder over a 1000 × 1000 source like #4112's: silhouettes at x 100 and 900, edges at + /// y 200 (top) and 800 (bottom), ellipses 0.3 deep, half wrapped round. + const CYL: [f64; 7] = [100.0, 800.0, 900.0, 200.0, 0.3, 0.3, 0.5]; + + #[test] + fn cylinder_style_is_read_and_named_but_not_a_bend_preset() { + for s in ["cylinder", "warpCylinder", "Cylinder"] { + assert_eq!(WarpStyle::parse(s), Some(WarpStyle::Cylinder), "{s}"); + } + assert_eq!(WarpStyle::Cylinder.psd_name(), "warpCylinder"); + assert_eq!(WarpStyle::Cylinder.id(), "cylinder"); + assert!(!WarpStyle::Cylinder.is_preset()); + assert!(StyleWarp::new(WarpStyle::Cylinder, 50.0, 0.0, 0.0, false, B).is_none()); + // The Warp menus list what they can edit. + assert!(WarpStyle::all().all(|s| s != WarpStyle::Cylinder)); + assert_eq!(WarpStyle::all().filter(|s| s.is_preset()).count(), 15); + } + + #[test] + fn cylinder_values_that_describe_no_cylinder_are_refused() { + assert!(cylinder_mesh(&CYL[..6]).is_none(), "six values"); + assert!(cylinder_mesh(&[CYL.as_slice(), &[0.0]].concat()).is_none(), "eight values"); + for i in 0..7 { + for bad in [f64::NAN, f64::INFINITY] { + let mut v = CYL; + v[i] = bad; + assert!(cylinder_mesh(&v).is_none(), "value {i} = {bad}"); + } + } + let mut v = CYL; + v[2] = v[0]; + assert!(Warp::cylinder(&v, [0.0, 0.0, 1000.0, 1000.0]).is_none(), "no width"); + v[2] = 50.0; + assert!(Warp::cylinder(&v, [0.0, 0.0, 1000.0, 1000.0]).is_none(), "right silhouette left of the left one"); + } + + #[test] + fn cylinder_wraps_the_contents_between_its_silhouettes() { + let w = Warp::cylinder(&CYL, [0.0, 0.0, 1000.0, 1000.0]).unwrap(); + assert_eq!(w.style, WarpStyle::Cylinder); + assert_eq!(w.values, CYL); + assert!(!w.is_identity()); + let m = w.mesh.as_ref().unwrap(); + assert_eq!((m.nx(), m.ny()), (13, 4)); + let [ul, ..] = cylinder_shape(0.5); + assert!((m.us[1] - ul).abs() < 1e-12 && (m.us[3] - (1.0 - ul)).abs() < 1e-12); + // The middle of the contents is at the front: centred, on the ellipses' near side. + assert!(close(w.map(500.0, 0.0), (500.0, 200.0 + 0.3 * 400.0), 1e-9)); + assert!(close(w.map(500.0, 1000.0), (500.0, 800.0 + 0.3 * 400.0), 1e-9)); + // What wraps round the back lies on the silhouettes, at the edges' ends. + for x in [0.0, ul * 500.0, ul * 1000.0] { + assert!(close(w.map(x, 0.0), (100.0, 200.0), 1e-9), "{x}"); + assert!(close(w.map(1000.0 - x, 1000.0), (900.0, 800.0), 1e-9), "{x}"); + } + // Across the front x only grows, and the two halves mirror each other. + let mut last = f64::MIN; + for i in 0..=200 { + let x = 1000.0 * (ul + (1.0 - 2.0 * ul) * f64::from(i) / 200.0); + let (px, py) = w.map(x, 400.0); + assert!(px >= last - 1e-9, "x {x}"); + last = px; + let (qx, qy) = w.map(1000.0 - x, 400.0); + assert!(close((px - 500.0, py), (500.0 - qx, qy), 1e-9), "x {x}"); + } + // Every point stays inside the control-point box the placement frames. + let cb = m.control_bounds(); + let ob = w.output_bounds(); + assert!(ob[0] >= cb[0] - 1e-9 && ob[1] >= cb[1] - 1e-9 && ob[2] <= cb[2] + 1e-9 && ob[3] <= cb[3] + 1e-9); + assert!((cb[0] - 100.0).abs() < 1e-9 && (cb[2] - 900.0).abs() < 1e-9); + // The box's bottom is the lower ellipse's deepest control point (`my` > 1). + assert!((cb[3] - (800.0 + 0.3 * 400.0 * cylinder_shape(0.5)[3])).abs() < 1e-9); + assert_eq!(w.to_mesh(4, 4), *m, "a cylinder is its own mesh"); + } + + #[test] + fn cylinder_shape_follows_the_measured_rows() { + assert_eq!(cylinder_shape(0.0), CYLINDER_SHAPE[0]); + assert_eq!(cylinder_shape(1.0), CYLINDER_SHAPE[40]); + assert_eq!(cylinder_shape(-3.0), CYLINDER_SHAPE[0]); + assert_eq!(cylinder_shape(7.0), CYLINDER_SHAPE[40]); + assert_eq!(cylinder_shape(f64::NAN), CYLINDER_SHAPE[0]); + let mid = cylinder_shape(0.0125); + for k in 0..4 { + assert!((mid[k] - (CYLINDER_SHAPE[0][k] + CYLINDER_SHAPE[1][k]) / 2.0).abs() < 1e-12); + } + // The more it wraps, the less of the contents is on the back. + assert!(CYLINDER_SHAPE.windows(2).all(|r| r[1][0] < r[0][0])); + } + + #[test] + fn is_identity_rejects_malformed_custom_mesh_without_allocating() { + let us: Vec = (0..=1000).map(|i| i as f64 / 1000.0).collect(); + let vs: Vec = (0..=1000).map(|i| i as f64 / 1000.0).collect(); + let malformed = BezierMesh { us, vs, points: Vec::new() }; + assert!(!malformed.is_valid()); + let w = Warp::custom(malformed.clone(), B); + assert!(!w.is_identity()); + assert_eq!(malformed.eval(0.5, 0.5), [0.0, 0.0]); + } + + #[test] + fn locate_binary_search_matches_linear_scan() { + let knots = [0.0, 0.1, 0.25, 0.5, 0.85, 1.0]; + let linear = |s: f64| { + let n = knots.len() - 1; + let s = if s.is_finite() { s.clamp(0.0, 1.0) } else { 0.0 }; + let mut i = 0; + while i + 1 < n && s > knots[i + 1] { + i += 1; + } + let w = knots[i + 1] - knots[i]; + (i, if w > 0.0 { (s - knots[i]) / w } else { 0.0 }) + }; + for k in -10..=110 { + let s = k as f64 / 100.0; + assert_eq!(BezierMesh::locate(&knots, s), linear(s), "s={s}"); + } + for &s in &knots { + assert_eq!(BezierMesh::locate(&knots, s), linear(s), "knot={s}"); + } + assert_eq!(BezierMesh::locate(&knots, f64::NAN), linear(f64::NAN)); + } } diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 81b51f4c35..e33ec9c01a 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -14,6 +14,9 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } +# Camera Raw's per-pixel stages (`camera_raw.rs`) take their constants from the CPU develop. +photocraft-algo = { workspace = true } +bytemuck = { workspace = true } # Same major version as eframe's wgpu so the app's device can be shared. wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] } log = { workspace = true } diff --git a/crates/gpu/examples/bench_camera_raw.rs b/crates/gpu/examples/bench_camera_raw.rs new file mode 100644 index 0000000000..be206fe382 --- /dev/null +++ b/crates/gpu/examples/bench_camera_raw.rs @@ -0,0 +1,116 @@ +//! Camera Raw's preview develop on the 858×572 proxy of a 6000×4000 image: the CPU develop +//! against the GPU pass (upload, render, readback), for per-pixel settings. +//! `cargo run --release -p photocraft-gpu --example bench_camera_raw [-- --quick]` +use std::time::Instant; + +use photocraft_algo::camera_raw::{CameraRaw, Wheel, develop, per_pixel, white_black_points}; +use photocraft_gpu::CameraRawDevelop; + +fn median(mut v: Vec) -> f64 { + v.sort_by(f64::total_cmp); + v[v.len() / 2] +} + +fn time(runs: usize, mut f: impl FnMut()) -> f64 { + f(); + median( + (0..runs) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1e3 + }) + .collect(), + ) +} + +/// [`time`] for a call that can fail: its first error ends the benchmark instead of being timed. +fn try_time(runs: usize, mut f: impl FnMut() -> Result<(), E>) -> Result { + let mut failed = None; + let ms = time(runs, || { + if failed.is_none() { + failed = f().err(); + } + }); + failed.map_or(Ok(ms), Err) +} + +fn main() -> Result<(), Box> { + let runs = if std::env::args().any(|a| a == "--quick") { 5 } else { 40 }; + let (w, h) = (858usize, 572usize); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }; + let adapter = pollster::block_on(instance.request_adapter(&options))?; + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; + let info = adapter.get_info(); + println!("adapter: {} ({:?}), proxy {w}x{h}, median of {runs}", info.name, info.backend); + let built = Instant::now(); + let mut pass = CameraRawDevelop::new(&device)?; + println!("pipeline build: {:.1} ms", built.elapsed().as_secs_f64() * 1e3); + let src: Vec<[f32; 4]> = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32); + [0.1 + 0.8 * x, 0.2 + 0.6 * y, 0.6 - 0.5 * x * y, 1.0] + }) + .collect(); + let scale = w as f32 / 6000.0; + let mut hsl = [0.0; 8]; + hsl[1] = 30.0; + hsl[5] = -40.0; + let sets = [ + ( + "Basic (WB, Exposure, Contrast, Whites, Blacks)", + CameraRaw { temperature: 20.0, exposure: 0.4, contrast: 25.0, whites: 30.0, blacks: -20.0, pixel_scale: scale, ..Default::default() }, + ), + ( + "every per-pixel stage", + CameraRaw { + temperature: 20.0, + tint: 5.0, + exposure: 0.4, + contrast: 25.0, + whites: 30.0, + blacks: -20.0, + vibrance: 30.0, + saturation: 10.0, + curve_lights: 20.0, + point_curve: vec![[0.0, 8.0], [128.0, 140.0], [255.0, 250.0]], + hsl_hue: hsl, + hsl_sat: hsl, + hsl_lum: hsl, + grade_shadows: Wheel { hue: 210.0, sat: 30.0, lum: 0.0 }, + grade_highlights: Wheel { hue: 40.0, sat: 20.0, lum: 5.0 }, + vignette_amount: -30.0, + grain_amount: 25.0, + pixel_scale: scale, + ..Default::default() + }, + ), + ]; + for (name, p) in &sets { + let cpu = time(runs, || { + let mut px = src.clone(); + develop(&mut px, w, h, p, false); + }); + let points_ms = time(runs, || { + std::hint::black_box(white_black_points(&src, w, h, p)); + }); + let points = white_black_points(&src, w, h, p); + let stages = per_pixel(p, w, h, false, points).ok_or("not per pixel")?; + let size = [w as u32, h as u32]; + let upload = try_time(runs, || pass.develop(&device, &queue, None, &src, None, size, &stages).map(drop))?; + let cached = try_time(runs, || pass.develop(&device, &queue, None, &src, Some(1), size, &stages).map(drop))?; + println!("{name}:"); + println!(" CPU develop {cpu:7.2} ms"); + println!(" GPU, source uploaded every time {upload:7.2} ms"); + println!(" GPU, source cached (slider drag) {cached:7.2} ms"); + println!(" Whites/Blacks points on the CPU {points_ms:7.2} ms (cached per WB/Exposure/Contrast in the dialog)"); + } + let shadows = CameraRaw { shadows: 50.0, pixel_scale: scale, ..Default::default() }; + let cpu = time(runs.min(10), || { + let mut px = src.clone(); + develop(&mut px, w, h, &shadows, false); + }); + println!("for reference, Shadows +50 (CPU only): {cpu:.2} ms"); + Ok(()) +} diff --git a/crates/gpu/examples/bench_shape_drag.rs b/crates/gpu/examples/bench_shape_drag.rs new file mode 100644 index 0000000000..820c1e187f --- /dev/null +++ b/crates/gpu/examples/bench_shape_drag.rs @@ -0,0 +1,82 @@ +//! Styled shape group translation: synchronized frame time and effect-map rebuild counters. +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Fill, Layer, LayerContent, Path, ShapeLayer, Subpath}; +use photocraft_geom::{Affine, Size}; +use photocraft_gpu::Compositor; +use std::time::Instant; + +/// Translate the fixture's paths and raster caches together, as a group drag preview does. +fn moved(base: &Document, offset: i32) -> Document { + let mut doc = base.clone(); + if let Some(group) = doc.layers.last_mut().and_then(Layer::children_mut) { + for layer in group { + if let LayerContent::Shape(shape) = &mut layer.content { + shape.path = shape.path.transform(&Affine::translate(f64::from(offset), f64::from(offset))); + if let Some(cache) = &mut shape.cache { + *cache = cache.translated(offset, offset, photocraft_compose::bounds::content_bounds(cache)); + } + } + } + } + doc +} +/// Measure synchronized drag frames; quick mode also verifies fresh-render parity. +fn main() -> Result<(), Box> { + let quick = std::env::args().any(|a| a == "--quick"); + let size = if quick { Size::new(1600, 1200) } else { Size::new(6000, 4000) }; + let mut doc = Document::with_background("styled shapes", size, ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut children = Vec::new(); + for i in 0..12 { + let x = 100.1 + f64::from(i % 4) * f64::from(size.width) / 5.0; + let y = 100.2 + f64::from(i / 4) * f64::from(size.height) / 4.0; + let w = f64::from(size.width) / 7.0; + let h = f64::from(size.height) / 6.0; + let mut shape = ShapeLayer { + path: Path::new(vec![Subpath::polygon(&[(x, y), (x + w, y), (x + w, y + h), (x, y + h)])]), + fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.8))), + ..Default::default() + }; + shape.cache = Some(photocraft_vector::render_shape(&shape, doc.pixel_format(), doc.bounds())); + let mut layer = Layer::new(format!("shape {i}"), LayerContent::Shape(shape)); + layer.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + children.push(layer); + } + doc.layers.push(Layer::group("group", children)); + let adapter = pollster::block_on( + wgpu::Instance::default() + .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), + )?; + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; + println!("adapter={:?} document={}x{}", adapter.get_info().name, size.width, size.height); + let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter))?; + let mut samples = Vec::new(); + for frame in 0..8 { + let start = Instant::now(); + let current = moved(&doc, frame * 3); + let preview_ms = start.elapsed().as_secs_f64() * 1000.0; + let stats = comp.render(&device, &queue, ¤t, current.bounds(), |_, _| {})?; + device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None })?; + let ms = start.elapsed().as_secs_f64() * 1000.0; + println!( + "frame={frame} ms={ms:.3} preview_ms={preview_ms:.3} fx_shapes={} fx_programs={} fx_pixels={}", + stats.fx_shapes, stats.fx_programs, stats.fx_pixels + ); + if frame > 1 { + samples.push(ms); + } + } + samples.sort_by(f64::total_cmp); + println!("warm_median_ms={:.3}", samples.get(samples.len() / 2).copied().unwrap_or_default()); + if quick { + let current = moved(&doc, 21); + let warm = photocraft_gpu::render_to_vec(&mut comp, &device, &queue, ¤t, current.bounds())?; + let mut fresh = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter))?; + let expected = photocraft_gpu::render_to_vec(&mut fresh, &device, &queue, ¤t, current.bounds())?; + let difference = warm.iter().zip(&expected).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); + println!("fresh_render_max_difference={difference}"); + if difference >= 1.0 / 255.0 { + return Err("reused effect maps differ from fresh rendering".into()); + } + } + Ok(()) +} diff --git a/crates/gpu/examples/zero_block_probe.rs b/crates/gpu/examples/zero_block_probe.rs new file mode 100644 index 0000000000..ef9a5b44a2 --- /dev/null +++ b/crates/gpu/examples/zero_block_probe.rs @@ -0,0 +1,195 @@ +//! Probe: do textures uploaded like egui-wgpu's (TEXTURE_BINDING | COPY_DST, one full +//! `write_texture`) read back exactly what was written? Uploads mostly-transparent images with a +//! few opaque texels, samples every texel in a render pass, reads the target back and counts +//! texels that differ. Between uploads it frees render targets cleared to red, so stale memory is +//! red if the driver hands it back without honouring the copy. +//! +//! cargo run --release -p photocraft-gpu --example zero_block_probe -- [dx12|vulkan|gl] [rounds] [guard] [adapter=] +//! +//! `adapter=` probes the first adapter whose name contains `` (case-insensitive), e.g. +//! `adapter=iris` on a machine that also has a discrete GPU; without it wgpu picks its default adapter. + +use wgpu::util::DeviceExt; + +const SHADER: &str = r" +@group(0) @binding(0) var t: texture_2d; +@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let p = array(vec2(-1.0, -3.0), vec2(-1.0, 1.0), vec2(3.0, 1.0)); + return vec4(p[i], 0.0, 1.0); +} +@fragment fn fs(@builtin(position) p: vec4) -> @location(0) vec4 { + return textureLoad(t, vec2(p.xy), 0); +} +"; + +fn main() { + let args: Vec = std::env::args().collect(); + let backends = match args.get(1).map(String::as_str) { + Some("vulkan") => wgpu::Backends::VULKAN, + Some("gl") => wgpu::Backends::GL, + _ => wgpu::Backends::DX12, + }; + let rounds: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(300); + // `guard`: transparent texels are uploaded as (0, 0, 0, 1), as `photocraft_ui_egui::texel_guard` does. + let guard = args.get(3).is_some_and(|a| a == "guard"); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapter = match args.iter().find_map(|a| a.strip_prefix("adapter=")).map(str::to_lowercase) { + Some(want) => { + let adapters = pollster::block_on(instance.enumerate_adapters(backends)); + let names: Vec = adapters.iter().map(|a| a.get_info().name).collect(); + adapters + .into_iter() + .find(|a| a.get_info().name.to_lowercase().contains(&want)) + .unwrap_or_else(|| panic!("no adapter matches {want:?}; found {names:?}")) + } + None => pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).expect("adapter"), + }; + let info = adapter.get_info(); + println!("adapter: {} ({:?}) driver {} {}", info.name, info.backend, info.driver, info.driver_info); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).expect("device"); + + let fmt = wgpu::TextureFormat::Rgba8Unorm; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: None, + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { format: fmt, blend: None, write_mask: wgpu::ColorWrites::ALL })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + + let mut bad_textures = 0; + let mut bad_texels = 0usize; + for round in 0..rounds { + // Vary the size like the brush previews and icons do. + let (w, h) = (16 + (round as u32 * 37) % 300, 16 + (round as u32 * 13) % 60); + // Free some red render targets first: their memory is what a not-zeroed heap hands back. + for _ in 0..2 { + let rt = device.create_texture(&wgpu::TextureDescriptor { + label: Some("red-rt"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = rt.create_view(&Default::default()); + let mut enc = device.create_command_encoder(&Default::default()); + enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: 1.0, g: 0.03, b: 0.03, a: 1.0 }), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + queue.submit([enc.finish()]); + } + // The upload: transparent except a diagonal band of opaque grey, like a stroke strip. + let mut px = if guard { [0u8, 0, 0, 1].repeat((w * h) as usize) } else { vec![0u8; (w * h * 4) as usize] }; + for y in 0..h { + for x in 0..w { + if (x as i64 * h as i64 / w as i64 - y as i64).abs() < 3 { + let i = ((y * w + x) * 4) as usize; + px[i..i + 4].copy_from_slice(&[200, 200, 200, 255]); + } + } + } + let tex = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("egui-like"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &px, + ); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("target"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&tex.create_view(&Default::default())) }], + }); + let tview = target.create_view(&Default::default()); + let row = (w * 4).div_ceil(256) * 256; + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(row * h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut enc = device.create_command_encoder(&Default::default()); + { + let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &tview, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLUE), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + } + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: &target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: None } }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + queue.submit([enc.finish()]); + buf.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::wait_indefinitely()).expect("poll"); + let data = buf.slice(..).get_mapped_range().expect("mapped"); + let mut bad = 0; + let mut first = None; + for y in 0..h { + for x in 0..w { + let got = &data[(y * row + x * 4) as usize..(y * row + x * 4 + 4) as usize]; + let want = &px[((y * w + x) * 4) as usize..((y * w + x) * 4 + 4) as usize]; + if got != want { + bad += 1; + first.get_or_insert((x, y, got.to_vec())); + } + } + } + if bad > 0 { + bad_textures += 1; + bad_texels += bad; + if bad_textures <= 10 { + println!("round {round}: {w}x{h} texture has {bad} wrong texels, first {first:?}"); + } + } + } + println!("{bad_textures}/{rounds} textures read back wrong ({bad_texels} texels)"); +} diff --git a/crates/gpu/src/camera_raw.rs b/crates/gpu/src/camera_raw.rs new file mode 100644 index 0000000000..3f1833a54b --- /dev/null +++ b/crates/gpu/src/camera_raw.rs @@ -0,0 +1,418 @@ +//! Camera Raw's per-pixel develop on the GPU, for the dialog's interactive preview (#6916). +//! +//! One fragment pass (`camera_raw.wgsl`) runs the stages that read only their own pixel: white +//! balance and exposure, Contrast without Texture or Clarity, Whites and Blacks, Vibrance and +//! Saturation, the tone curves, the Color Mixer, Color Grading, the vignette and grain. Their +//! constants and tables come from [`photocraft_algo::camera_raw::per_pixel`], which the CPU +//! develop builds its own from, so both evaluate the same maths; the curves and the Whites and +//! Blacks rows are rows of one `R32Float` texture. Settings with a spatial stage (Dehaze, Shadows +//! and Highlights, Texture, Clarity, Detail) have no [`PerPixel`] and stay on the CPU. +//! +//! The pipeline is built on demand ([`CameraRawDevelop::new`]), not with the compositor (#6419). +//! It renders into `Rgba32Float`; [`CameraRawDevelop::supported`] says whether the adapter can. + +use photocraft_algo::camera_raw::{CURVE_LUT, PerPixel, VignetteStyle, WB_STEPS}; + +use crate::Unsupported; + +/// The pass's WGSL source. +pub const CAMERA_RAW_SHADER: &str = include_str!("camera_raw.wgsl"); + +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; +/// `Params` in the shader: 19 rows of 16 bytes. +const PARAMS_SIZE: u64 = 19 * 16; +/// Rows of the table texture: parametric, master, red, green, blue curves; Whites, Blacks. +const LUT_ROWS: u32 = 7; +const LUT_WIDTH: u32 = CURVE_LUT as u32; +const BYTES_PER_PIXEL: u32 = 16; + +struct Image { + key: Option, + size: [u32; 2], + texture: wgpu::Texture, + view: wgpu::TextureView, +} + +impl Image { + fn new(device: &wgpu::Device, label: &str, size: [u32; 2], usage: wgpu::TextureUsages) -> Self { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + Image { key: None, size, texture, view } + } +} + +/// The Camera Raw preview pass and its textures (reused while the size stays). +pub struct CameraRawDevelop { + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + params: wgpu::Buffer, + luts: wgpu::Texture, + luts_view: wgpu::TextureView, + source: Option, + target: Option, + #[cfg(not(target_arch = "wasm32"))] + readback: Option, +} + +impl CameraRawDevelop { + /// Whether `adapter` can render the pass's `Rgba32Float` output. + pub fn supported(adapter: &wgpu::Adapter) -> bool { + adapter.get_texture_format_features(FORMAT).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) + } + + /// Builds the pipeline. A shader or pipeline the driver can't build is an `Err`, so the + /// caller keeps the CPU develop (where errors only arrive asynchronously, on WebGPU, creation + /// is assumed to have worked). + pub fn new(device: &wgpu::Device) -> Result { + match crate::first_error(device, || Self::build(device)) { + (dev, None) => Ok(dev), + (_, Some(e)) => Err(Unsupported(format!("couldn't build the Camera Raw pipeline: {e}"))), + } + } + + fn build(device: &wgpu::Device) -> Self { + let module = device + .create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_camera_raw"), source: wgpu::ShaderSource::Wgsl(CAMERA_RAW_SHADER.into()) }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_camera_raw"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(PARAMS_SIZE), + }, + count: None, + }, + crate::tex_entry(1), + crate::tex_entry(2), + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pc_camera_raw"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("pc_camera_raw"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { format: FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); + let params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_camera_raw_params"), + size: PARAMS_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let luts = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_camera_raw_luts"), + size: wgpu::Extent3d { width: LUT_WIDTH, height: LUT_ROWS, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let luts_view = luts.create_view(&Default::default()); + CameraRawDevelop { + pipeline, + layout, + params, + luts, + luts_view, + source: None, + target: None, + #[cfg(not(target_arch = "wasm32"))] + readback: None, + } + } + + /// Records the develop of `source` (`w × h` straight RGBA, display-encoded) with `stages` + /// into `encoder` and returns the `Rgba32Float` texture it renders. The source is uploaded + /// unless the last one had the same `key` and size (`None` always uploads). + #[allow(clippy::too_many_arguments)] + pub fn encode( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + source: &[[f32; 4]], + key: Option, + size: [u32; 2], + stages: &PerPixel, + ) -> Result<&wgpu::Texture, Unsupported> { + check_size(device, source, size)?; + let [w, h] = size; + let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + let src = match self.source.take() { + Some(s) if s.size == size => s, + _ => Image::new(device, "pc_camera_raw_source", size, usage), + }; + let src = self.source.insert(src); + if key.is_none() || src.key != key { + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(source), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * BYTES_PER_PIXEL), rows_per_image: Some(h) }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + src.key = key; + } + queue.write_buffer(&self.params, 0, ¶ms(size, stages)); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &self.luts, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(&tables(stages)), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT_WIDTH * 4), rows_per_image: Some(LUT_ROWS) }, + wgpu::Extent3d { width: LUT_WIDTH, height: LUT_ROWS, depth_or_array_layers: 1 }, + ); + let target = match self.target.take() { + Some(t) if t.size == size => t, + _ => Image::new(device, "pc_camera_raw_target", size, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC), + }; + let target = self.target.insert(target); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_camera_raw"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: self.params.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&src.view) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.luts_view) }, + ], + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pc_camera_raw"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &target.view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &group, &[]); + pass.draw(0..3, 0..1); + } + Ok(&target.texture) + } + + /// Develops `source` and reads the result back (blocking; native only, the web has no + /// blocking wait). GPU errors and a lost device are `Err`; `health`, when the device is + /// watched, records a loss. + #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::too_many_arguments)] + pub fn develop( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + health: Option<&crate::DeviceHealth>, + source: &[[f32; 4]], + key: Option, + size: [u32; 2], + stages: &PerPixel, + ) -> Result, Unsupported> { + if let Some(f) = health.and_then(crate::DeviceHealth::fault) { + return Err(Unsupported(f.to_string())); + } + let (result, error) = crate::first_error(device, || self.develop_inner(device, queue, health, source, key, size, stages)); + match error { + Some(e) => { + // A half-done upload must not be taken for the cached source next time. + self.source = None; + Err(Unsupported(format!("GPU Camera Raw develop failed: {e}"))) + } + None => result, + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::too_many_arguments)] + fn develop_inner( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + health: Option<&crate::DeviceHealth>, + source: &[[f32; 4]], + key: Option, + size: [u32; 2], + stages: &PerPixel, + ) -> Result, Unsupported> { + check_size(device, source, size)?; + let [w, h] = size; + let row = (w * BYTES_PER_PIXEL).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes = u64::from(row) * u64::from(h); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_camera_raw") }); + self.encode(device, queue, &mut encoder, source, key, size, stages)?; + let Some(target) = self.target.as_ref().map(|t| &t.texture) else { return Err(Unsupported("no Camera Raw target".into())) }; + let buffer = match self.readback.take() { + Some(b) if b.size() == bytes => b, + _ => device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_camera_raw_readback"), + size: bytes, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }), + }; + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { buffer: &buffer, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(h) } }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + let index = queue.submit([encoder.finish()]); + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + match health { + Some(hl) => { + if !hl.wait(device, Some(index)) { + return Err(Unsupported(hl.fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string()))); + } + } + None => { + device.poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: None }).map_err(|e| Unsupported(format!("GPU wait failed: {e}")))?; + } + } + let mut out = Vec::with_capacity(source.len()); + { + let data = buffer.slice(..).get_mapped_range().map_err(|e| Unsupported(format!("GPU readback failed: {e:?}")))?; + let width = w as usize * BYTES_PER_PIXEL as usize; + for y in 0..h as usize { + let start = y * row as usize; + let line = data.get(start..start + width).ok_or_else(|| Unsupported("short GPU readback".into()))?; + out.extend(bytemuck::pod_collect_to_vec::(line)); + } + } + buffer.unmap(); + self.readback = Some(buffer); + Ok(out) + } +} + +fn check_size(device: &wgpu::Device, source: &[[f32; 4]], [w, h]: [u32; 2]) -> Result<(), Unsupported> { + let max = device.limits().max_texture_dimension_2d; + if w == 0 || h == 0 || w > max || h > max { + return Err(Unsupported(format!("a {w}×{h} Camera Raw preview doesn't fit the GPU's {max} px textures"))); + } + if source.len() as u64 != u64::from(w) * u64::from(h) { + return Err(Unsupported(format!("{} Camera Raw preview pixels for {w}×{h}", source.len()))); + } + Ok(()) +} + +/// Flags of `Params.flags`, in the shader's bit order. +fn flags(s: &PerPixel) -> u32 { + let wb = s.whites_blacks.as_ref(); + let on = [ + s.gains.is_some(), + s.contrast.is_some(), + wb.is_some_and(|w| w.whites.is_some()), + wb.is_some_and(|w| w.blacks.is_some()), + wb.is_some_and(|w| w.per_channel), + s.presence.is_some(), + s.parametric.is_some(), + s.point[0].is_some(), + s.point[1].is_some(), + s.point[2].is_some(), + s.point[3].is_some(), + s.hsl.is_some(), + s.grade.is_some(), + s.vignette.is_some(), + s.grain.is_some(), + s.clamp, + ]; + on.iter().enumerate().fold(0, |f, (bit, on)| if *on { f | 1 << bit } else { f }) +} + +/// The shader's `Params` for a `size` image. +fn params(size: [u32; 2], s: &PerPixel) -> Vec { + let g = s.gains.unwrap_or([1.0; 3]); + let presence = s.presence.unwrap_or_default(); + let hsl = s.hsl.unwrap_or_default(); + let grade = s.grade.as_ref(); + let vig = s.vignette.as_ref(); + let grain = s.grain.as_ref(); + let mut out: Vec = Vec::with_capacity(PARAMS_SIZE as usize); + out.extend([size[0], size[1], flags(s)].iter().flat_map(|v| v.to_le_bytes()).chain(s.encoding.to_le_bytes())); + let mut f = |v: [f32; 4]| out.extend(v.iter().flat_map(|x| x.to_le_bytes())); + f([g[0], g[1], g[2], s.contrast.unwrap_or(0.0)]); + f([presence[0], presence[1], 0.0, 0.0]); + for row in hsl { + f([row[0], row[1], row[2], row[3]]); + f([row[4], row[5], row[6], row[7]]); + } + for k in 0..4 { + let (t, l) = grade.map_or(([0.0; 3], 0.0), |gr| (gr.tints[k], gr.lums[k])); + f([t[0], t[1], t[2], l]); + } + f(grade.map_or([0.0; 4], |gr| [gr.blend, gr.balance, 0.0, 0.0])); + let style = |v: VignetteStyle| match v { + VignetteStyle::HighlightPriority => 0.0, + VignetteStyle::ColorPriority => 1.0, + VignetteStyle::PaintOverlay => 2.0, + }; + f(vig.map_or([0.0, 0.0, 0.0, 2.0], |v| [v.amount, v.highlights, style(v.style), v.power])); + f(vig.map_or([0.0, 0.0, 1.0, 1.0], |v| [v.centre[0], v.centre[1], v.axes[0], v.axes[1]])); + f(vig.map_or([0.0; 4], |v| [v.edges[0], v.edges[1], 0.0, 0.0])); + f(grain.map_or([1.0, 0.0, 0.0, 1.0], |gr| [gr.cell, gr.roughness, gr.amount, gr.scale])); + let seed = grain.map_or(0, |gr| gr.seed); + let fine = seed.wrapping_add(7); + out.extend([seed as u32, (seed >> 32) as u32, fine as u32, (fine >> 32) as u32].iter().flat_map(|v| v.to_le_bytes())); + out +} + +/// The table texture's rows (see [`LUT_ROWS`]); rows of stages that are off stay zero. +fn tables(s: &PerPixel) -> Vec { + let mut t = vec![0.0f32; (LUT_WIDTH * LUT_ROWS) as usize]; + let mut put = |row: usize, v: &[f32]| { + let start = row * LUT_WIDTH as usize; + let n = v.len().min(LUT_WIDTH as usize); + if let (Some(dst), Some(src)) = (t.get_mut(start..start + n), v.get(..n)) { + dst.copy_from_slice(src); + } + }; + if let Some(l) = &s.parametric { + put(0, l); + } + for (k, l) in s.point.iter().enumerate() { + if let Some(l) = l { + put(1 + k, l); + } + } + if let Some(wb) = &s.whites_blacks { + if let Some(r) = &wb.whites { + put(5, r); + } + if let Some(r) = &wb.blacks { + put(6, r); + } + } + t +} + +// The Whites and Blacks rows fit a table row. +const _: () = assert!(WB_STEPS <= CURVE_LUT); diff --git a/crates/gpu/src/camera_raw.wgsl b/crates/gpu/src/camera_raw.wgsl new file mode 100644 index 0000000000..abad7cbbc1 --- /dev/null +++ b/crates/gpu/src/camera_raw.wgsl @@ -0,0 +1,416 @@ +// Camera Raw's per-pixel develop stages (`photocraft_algo::camera_raw::develop`, constants from +// `camera_raw::per_pixel`), one fragment per pixel. Each function mirrors the CPU code it is +// named after, in the same order of operations; `camera_raw.rs` holds the bindings. + +struct Params { + size: vec2, + flags: u32, + // The document's transfer curve: 0 for sRGB's, else a power-law gamma. + encoding: f32, + // White balance gains (rgb), Contrast's strength (w). + gains: vec4, + // Vibrance, Saturation. + presence: vec4, + // Color Mixer bands 0-3 and 4-7: hue, saturation, luminance. + hsl: array, 6>, + // Color Grading wheels: tint (rgb), luminance (w). + tints: array, 4>, + // Blending, Balance. + grade: vec4, + // [amount, highlights, style, power], [centre, axes], [edges]. + vignette: array, 3>, + // Cell, roughness, amount, full-resolution pixels per pixel. + grain: vec4, + // The two octaves' seeds as (low, high) words. + seeds: vec4, +} + +@group(0) @binding(0) var P: Params; +@group(0) @binding(1) var src: texture_2d; +// R32Float rows of `LUT_WIDTH`: parametric, master, red, green, blue curves; Whites, Blacks rows. +@group(0) @binding(2) var luts: texture_2d; + +const GAINS: u32 = 0u; +const CONTRAST: u32 = 1u; +const WHITES: u32 = 2u; +const BLACKS: u32 = 3u; +const PER_CHANNEL: u32 = 4u; +const PRESENCE: u32 = 5u; +const PARAMETRIC: u32 = 6u; +const MASTER: u32 = 7u; +const RED: u32 = 8u; +const HSL: u32 = 11u; +const GRADE: u32 = 12u; +const VIGNETTE: u32 = 13u; +const GRAIN: u32 = 14u; +const CLAMP: u32 = 15u; + +const CURVE_LUT: i32 = 1024; +const WB_STEPS: i32 = 33; +const TAU: f32 = 6.2831855; + +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 on(flag: u32) -> bool { + return (P.flags & (1u << flag)) != 0u; +} + +fn luma(c: vec3) -> f32 { + return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z; +} + +fn smoothstep_(e0: f32, e1: f32, x: f32) -> f32 { + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +fn rem_euclid(x: f32, m: f32) -> f32 { + let r = x % m; + return select(r, r + m, r < 0.0); +} + +fn srgb_to_linear(v: f32) -> f32 { + if v <= 0.04045 { + return v / 12.92; + } + return pow((v + 0.055) / 1.055, 2.4); +} + +fn linear_to_srgb(v: f32) -> f32 { + if v <= 0.0031308 { + return v * 12.92; + } + return 1.055 * pow(v, 1.0 / 2.4) - 0.055; +} + +fn decode(v: f32) -> f32 { + if P.encoding > 0.0 { + return pow(max(v, 0.0), P.encoding); + } + return srgb_to_linear(v); +} + +fn encode(v: f32) -> f32 { + if P.encoding > 0.0 { + return pow(max(v, 0.0), 1.0 / P.encoding); + } + return linear_to_srgb(v); +} + +fn lut_at(row: i32, i: i32) -> f32 { + return textureLoad(luts, vec2(i, row), 0).r; +} + +// `lut_eval`: linear inside 0..1, extended linearly outside (float documents). +fn lut_eval(row: i32, v: f32) -> f32 { + if v < 0.0 { + return v + lut_at(row, 0); + } + if v > 1.0 { + return lut_at(row, CURVE_LUT - 1) + (v - 1.0); + } + let x = v * f32(CURVE_LUT - 1); + let i = min(i32(x), CURVE_LUT - 2); + let t = x - f32(i); + let a = lut_at(row, i); + return a + (lut_at(row, i + 1) - a) * t; +} + +// `whites_blacks::curve` on one row. +fn wb_curve(row: i32, v: f32) -> f32 { + if v >= 1.0 { + return 1.0; + } + let p = max(v, 0.0) * f32(WB_STEPS - 1); + let j = min(i32(p), WB_STEPS - 2); + let s = p - f32(j); + let a = lut_at(row, j); + return a + (lut_at(row, j + 1) - a) * s; +} + +fn grey(v: f32) -> f32 { + var g = v; + if on(WHITES) { + g = wb_curve(5, g); + } + if on(BLACKS) { + g = wb_curve(6, g); + } + return g; +} + +// `whites_blacks::apply`. +fn whites_blacks(q: vec3) -> vec3 { + let lin = vec3(decode(q.x), decode(q.y), decode(q.z)); + var out = q; + if on(PER_CHANNEL) { + for (var c = 0; c < 3; c++) { + let over = max(lin[c] - 1.0, 0.0); + out[c] = encode(srgb_to_linear(grey(linear_to_srgb(min(lin[c], 1.0)))) + over); + } + return out; + } + let y = max(luma(lin), 0.0); + if y <= 1e-7 { + return q; + } + let y2 = srgb_to_linear(grey(linear_to_srgb(min(y, 1.0)))) + max(y - 1.0, 0.0); + let k = y2 / y; + for (var c = 0; c < 3; c++) { + out[c] = encode(lin[c] * k); + } + return out; +} + +fn rgb_to_hsv(c: vec3) -> vec3 { + let mx = max(max(c.x, c.y), c.z); + let mn = min(min(c.x, c.y), c.z); + let d = mx - mn; + var h = 0.0; + if d <= 1e-9 { + h = 0.0; + } else if mx == c.x { + h = 60.0 * rem_euclid((c.y - c.z) / d, 6.0); + } else if mx == c.y { + h = 60.0 * ((c.z - c.x) / d + 2.0); + } else { + h = 60.0 * ((c.x - c.y) / d + 4.0); + } + return vec3(h, select(0.0, d / mx, mx > 1e-9), mx); +} + +fn hsv_to_rgb(hue: f32, s: f32, v: f32) -> vec3 { + let h = rem_euclid(hue, 360.0) / 60.0; + let c = v * s; + let x = c * (1.0 - abs((h % 2.0) - 1.0)); + var rgb: vec3; + switch u32(h) { + case 0u: { rgb = vec3(c, x, 0.0); } + case 1u: { rgb = vec3(x, c, 0.0); } + case 2u: { rgb = vec3(0.0, c, x); } + case 3u: { rgb = vec3(0.0, x, c); } + case 4u: { rgb = vec3(x, 0.0, c); } + default: { rgb = vec3(c, 0.0, x); } + } + let m = v - c; + return rgb + vec3(m); +} + +const HSL_BANDS = array(0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0); + +fn band(k: i32, row: i32) -> f32 { + return P.hsl[row * 2 + k / 4][k % 4]; +} + +// `band_weights` folded into the three sums. +fn hsl_shift(h: f32) -> vec3 { + var bands = HSL_BANDS; + var d = vec3(0.0); + for (var k = 0; k < 8; k++) { + let c = bands[k]; + let prev = bands[(k + 7) % 8]; + let next = bands[(k + 1) % 8]; + let diff = ((h - c + 540.0) % 360.0) - 180.0; + let span = select(rem_euclid(c - prev, 360.0), rem_euclid(next - c, 360.0), diff >= 0.0); + let w = max(1.0 - abs(diff) / max(span, 1e-3), 0.0); + d += w * vec3(band(k, 0), band(k, 1), band(k, 2)); + } + return d; +} + +// `photo_util::hash01`: 64-bit words as (low, high). +fn mul_wide(a: u32, b: u32) -> vec2 { + let a0 = a & 0xffffu; + let a1 = a >> 16u; + let b0 = b & 0xffffu; + let b1 = b >> 16u; + let p00 = a0 * b0; + let p01 = a0 * b1; + let p10 = a1 * b0; + let p11 = a1 * b1; + let mid = (p00 >> 16u) + (p01 & 0xffffu) + (p10 & 0xffffu); + return vec2((p00 & 0xffffu) | (mid << 16u), p11 + (p01 >> 16u) + (p10 >> 16u) + (mid >> 16u)); +} + +fn mul64(a: vec2, b: vec2) -> vec2 { + let w = mul_wide(a.x, b.x); + return vec2(w.x, w.y + a.x * b.y + a.y * b.x); +} + +fn shr64(z: vec2, n: u32) -> vec2 { + return vec2((z.x >> n) | (z.y << (32u - n)), z.y >> n); +} + +fn i64_(v: i32) -> vec2 { + return vec2(bitcast(v), select(0u, 0xffffffffu, v < 0)); +} + +fn hash01(x: i32, y: i32, seed: vec2) -> f32 { + var z = mul64(i64_(x), vec2(0x7f4a7c15u, 0x9e3779b9u)) ^ mul64(i64_(y), vec2(0x27d4eb4fu, 0xc2b2ae3du)) ^ mul64(seed, vec2(0x9e3779f9u, 0x165667b1u)); + z = mul64(z ^ shr64(z, 30u), vec2(0x1ce4e5b9u, 0xbf58476du)); + z = mul64(z ^ shr64(z, 27u), vec2(0x133111ebu, 0x94d049bbu)); + z = z ^ shr64(z, 31u); + return f32(z.y >> 8u) / 16777216.0; +} + +fn value_noise(x: f32, y: f32, seed: vec2) -> f32 { + let xi = floor(x); + let yi = floor(y); + let tx = x - xi; + let ty = y - yi; + let sx = tx * tx * (3.0 - 2.0 * tx); + let sy = ty * ty * (3.0 - 2.0 * ty); + let ix = i32(xi); + let iy = i32(yi); + let h00 = hash01(ix, iy, seed); + let h10 = hash01(ix + 1, iy, seed); + let h01 = hash01(ix, iy + 1, seed); + let h11 = hash01(ix + 1, iy + 1, seed); + let top = h00 + (h10 - h00) * sx; + let bot = h01 + (h11 - h01) * sx; + return top + (bot - top) * sy; +} + +@fragment +fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { + let p = vec2(floor(pos.xy)); + let q = textureLoad(src, p, 0); + var c = q.rgb; + // 1. White balance and exposure. + if on(GAINS) { + for (var i = 0; i < 3; i++) { + c[i] = linear_to_srgb(max(srgb_to_linear(c[i]) * P.gains[i], 0.0)); + } + } + // 3. Contrast (without Texture and Clarity). + if on(CONTRAST) { + let l0 = max(luma(c), 0.0); + let lc = min(l0, 1.0); + var lv = lc; + let xx = clamp(lv, 0.0, 1.0); + lv += -P.gains.w * sin(TAU * xx) / TAU; + lv = max(lv, 0.0) + (l0 - lc); + if l0 > 1e-5 { + c = c * (lv / l0); + } else { + c = c + vec3(lv - l0); + } + } + // 3b. Whites and Blacks. + if on(WHITES) || on(BLACKS) { + c = whites_blacks(c); + } + // 4. Vibrance and Saturation. + if on(PRESENCE) { + let l = luma(c); + let mx = max(max(c.x, c.y), c.z); + let mn = min(min(c.x, c.y), c.z); + let sat = select(0.0, (mx - mn) / mx, mx > 1e-6); + let hue = rgb_to_hsv(c).x; + let skin = clamp(1.0 - (abs(hue - 25.0) / 25.0), 0.0, 1.0) * 0.5; + let k = 1.0 + P.presence.y + P.presence.x * (1.0 - sat) * (1.0 - skin); + c = vec3(l) + (c - vec3(l)) * max(k, 0.0); + } + // 5. Tone curves. + if on(PARAMETRIC) { + c = vec3(lut_eval(0, c.x), lut_eval(0, c.y), lut_eval(0, c.z)); + } + if on(MASTER) { + c = vec3(lut_eval(1, c.x), lut_eval(1, c.y), lut_eval(1, c.z)); + } + for (var i = 0; i < 3; i++) { + if on(RED + u32(i)) { + c[i] = lut_eval(2 + i, c[i]); + } + } + // 6. Color Mixer. + if on(HSL) { + let hsv = rgb_to_hsv(max(c, vec3(0.0))); + let s = hsv.y; + if s > 1e-4 { + let d = hsl_shift(hsv.x); + let nh = hsv.x + d.x / 100.0 * 30.0; + let ns = clamp(s * (1.0 + d.y / 100.0), 0.0, 1.0); + var out = hsv_to_rgb(nh, ns, hsv.z); + let l0 = luma(c); + let l1 = luma(out); + if l1 > 1e-6 { + out = out * l0 / l1; + } + let k = exp2(d.z / 100.0 * 1.0 * s); + c = out * k; + } + } + // 7. Color Grading. + if on(GRADE) { + let l = clamp(luma(c), 0.0, 1.0); + let blend = P.grade.x; + let bal = P.grade.y; + let sh = 1.0 - smoothstep_(0.0, 0.5 + bal + blend, l); + let hi = smoothstep_(0.5 + bal - blend, 1.0, l); + let mid = max(1.0 - sh - hi, 0.0); + let masks = vec4(sh, mid, hi, 1.0); + for (var k = 0; k < 4; k++) { + c += masks[k] * P.tints[k].xyz; + if P.tints[k].w != 0.0 { + c = c * exp2(masks[k] * P.tints[k].w / 100.0 * 0.6); + } + } + } + // 9. Vignette. + if on(VIGNETTE) { + let amt = P.vignette[0].x; + let hl = P.vignette[0].y; + let style = u32(P.vignette[0].z); + let pw = P.vignette[0].w; + let centre = P.vignette[1].xy; + let axes = P.vignette[1].zw; + let dx = abs((f32(p.x) + 0.5 - centre.x) / axes.x); + let dy = abs((f32(p.y) + 0.5 - centre.y) / axes.y); + let r = pow(pow(dx, pw) + pow(dy, pw), 1.0 / pw); + let m = smoothstep_(P.vignette[2].x, P.vignette[2].y, r); + if m > 0.0 { + let l = clamp(luma(c), 0.0, 1.0); + if style == 2u { + let goal = select(1.0, 0.0, amt < 0.0); + let k = m * abs(amt); + c += (vec3(goal) - c) * k; + } else { + var k = exp2(amt * 2.0 * m); + if amt < 0.0 && hl > 0.0 { + let protect = smoothstep_(0.6, 1.0, l) * hl; + k += (1.0 - k) * protect; + } + if style == 1u { + let nl = l * k; + c += vec3(nl - l); + } else { + c = c * k; + } + } + } + } + // 9. Grain, in full-resolution coordinates. + if on(GRAIN) { + let cell = P.grain.x; + let rough = P.grain.y; + let fx = f32(p.x) * P.grain.w / cell; + let fy = f32(p.y) * P.grain.w / cell; + let n = value_noise(fx, fy, P.seeds.xy) * (1.0 - rough) + (value_noise(fx * 2.3, fy * 2.3, P.seeds.zw) - 0.0) * rough; + let l = clamp(luma(c), 0.0, 1.0); + let g = (n - 0.5) * 2.0 * P.grain.z * (0.4 + 2.4 * l * (1.0 - l)); + c += vec3(g); + } + if on(CLAMP) { + c = clamp(c, vec3(0.0), vec3(1.0)); + } + return vec4(c, q.a); +} diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 1b62e2d46d..1a475827e8 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -139,9 +139,13 @@ fn hard_light(cb: f32, cs: f32) -> f32 { let s = 2.0 * cs - 1.0; return cb + s - cb * s; } +// The W3C curve, as Photoshop: D(cb) is the cubic below a quarter, sqrt above it (#8804). fn soft_light_ps(cb: f32, cs: f32) -> f32 { if (cs <= 0.5) { return 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs); } - return 2.0 * cb * (1.0 - cs) + sqrt(max(cb, 0.0)) * (2.0 * cs - 1.0); + let b = max(cb, 0.0); + var d = sqrt(b); + if (b <= 0.25) { d = ((16.0 * b - 12.0) * b + 4.0) * b; } + return 2.0 * cb * (1.0 - cs) + d * (2.0 * cs - 1.0); } fn vivid_light_ps(cb: f32, cs: f32) -> f32 { if (cs <= 0.5) { @@ -612,8 +616,22 @@ fn levels_float(record: i32, v: f32) -> f32 { return lut_at(base + 3) + mapped * (lut_at(base + 4) - lut_at(base + 3)); } -fn tone_channel(row: i32, v: f32, identity: f32) -> f32 { +fn tone_channel(row: i32, v: f32, identity: f32, extrapolate: f32) -> f32 { if (identity > 0.5) { return v; } + // Mirrors compose::adjust::lut_extended: past 0..1 along the end segments' slopes, measured + // over TONE_SLOPE_SPAN (16) table steps. + if (extrapolate > 0.5 && abs(v) <= 3.0e38) { + if (v < 0.0) { + let a = textureLoad(lut_tex, vec2(0, row), 0).r; + let b = textureLoad(lut_tex, vec2(16, row), 0).r; + return a + v * (b - a) * (4095.0 / 16.0); + } + if (v > 1.0) { + let a = textureLoad(lut_tex, vec2(4079, row), 0).r; + let b = textureLoad(lut_tex, vec2(4095, row), 0).r; + return b + (v - 1.0) * (b - a) * (4095.0 / 16.0); + } + } return lut(row, v); } @@ -647,7 +665,7 @@ fn adjust(c: vec3) -> vec3 { return clamp(v, vec3(0.0), vec3(1.0)); } case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure - case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut) + case 7: { return vec3(tone_channel(0, c.r, p0.x, p0.w), tone_channel(1, c.g, p0.y, p0.w), tone_channel(2, c.b, p0.z, p0.w)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut) case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); } case 8: { // Hue/Saturation let hsl = rgb_to_hsl(c); @@ -675,13 +693,30 @@ fn adjust(c: vec3) -> vec3 { if (light > 0.0) { lc = c + (1.0 - c) * light; } let lh = rgb_to_hsl(lc); hh = rem_euclid(lh.x + (p0.x + dh) / 360.0, 1.0); - // Raising saturation divides by 1 - s; lowering it multiplies by 1 + s. - if (sat > 0.0) { - ss = clamp(lh.y / max(1.0 - sat, 1.0 / 512.0), 0.0, 1.0); + var rgb: vec3; + if (hdr) { + // 32-bit: the chroma scaled around the lightness, unclipped, by Photoshop's factor + // in whole numbers (compose::adjust::hue_saturation_in, hdr_saturation_factor). + var k: f32; + if (sat <= 0.0) { + k = 1.0 - floor(-sat * 256.0 + 1e-4) / 256.0; + } else { + let steps = min(u32(floor(sat * 254.0 + 1e-4)), 254u); + k = f32(min(65280u / (255u - steps), 32767u)) / 256.0; + } + let d = (max(max(lc.r, lc.g), lc.b) - min(min(lc.r, lc.g), lc.b)) * k; + let lo = lh.z - d / 2.0; + let hi = lh.z + d / 2.0; + rgb = vec3(hue_ch(lo, hi, hh + 1.0 / 3.0), hue_ch(lo, hi, hh), hue_ch(lo, hi, hh - 1.0 / 3.0)); } else { - ss = clamp(lh.y * (1.0 + sat), 0.0, 1.0); + // Raising saturation divides by 1 - s; lowering it multiplies by 1 + s. + if (sat > 0.0) { + ss = clamp(lh.y / max(1.0 - sat, 1.0 / 512.0), 0.0, 1.0); + } else { + ss = clamp(lh.y * (1.0 + sat), 0.0, 1.0); + } + rgb = hsl_to_rgb(hh, ss, lh.z); } - var rgb = hsl_to_rgb(hh, ss, lh.z); // compose::adjust::range_lightness: towards the brightest channel when raised, the darkest when lowered. let rl = clamp(dl, -1.0, 1.0); if (rl > 0.0) { @@ -730,7 +765,8 @@ fn adjust(c: vec3) -> vec3 { if (c.r >= c.g && c.r >= c.b) { primary = 0; } else if (c.g >= c.b) { primary = 2; } var secondary = 5; if (c.b <= c.r && c.b <= c.g) { secondary = 1; } else if (c.r <= c.g) { secondary = 3; } - let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, 1.0); + // 32-bit: superwhites stay, up to Photoshop's 16 + let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, select(1.0, 16.0, hdr)); if (p1.z > 0.5) { if (hdr) { return set_lum(p2.rgb, g, F32_MAX); } // 32-bit: nothing clips at 1 return clamp(set_lum(p2.rgb, g, 1.0), vec3(0.0), vec3(1.0)); // Color blend of the tint @@ -935,6 +971,9 @@ fn fs_atop(in: VOut) -> @location(0) vec4 { let base = textureLoad(tex_a, p, 0); if (base.a <= 0.0) { return base; } let s = textureLoad(tex_b, p, 0); + if (op.mode == M_DISSOLVE) { + return select(base, vec4(s.rgb, base.a), dissolve_noise(doc_px(p)) < s.a * op.opacity); + } let r = composite_g(op.mode, vec4(base.rgb, 1.0), s, op.opacity, (op.flags & F_TEXT_GAMMA) != 0u); return vec4(r.rgb, base.a); } @@ -950,6 +989,7 @@ fn fs_adjust(in: VOut) -> @location(0) vec4 { } // Mix an adjusted result B back over the original A with blend mode, opacity and mask. +// Both ordinary and clipped adjustments use this pass, retaining the original alpha. @fragment fn fs_adjmix(in: VOut) -> @location(0) vec4 { let p = local(in.pos); @@ -957,6 +997,9 @@ fn fs_adjmix(in: VOut) -> @location(0) vec4 { let k = op.opacity * mask_value(doc_px(p)); if (k <= 0.0) { return b; } let a = textureLoad(tex_b, p, 0); + if (op.mode == M_DISSOLVE) { + return select(b, vec4(a.rgb, b.a), dissolve_noise(doc_px(p)) < k); + } let bl = blend_rgb(op.mode, b.rgb, a.rgb); return vec4(b.rgb + (bl - b.rgb) * k, b.a); } @@ -1118,6 +1161,16 @@ fn fx_color(d: vec2) -> vec4 { return op.color; } +// effects::paint: blend an effect's colour `s` (alpha = colour alpha × coverage × opacity) into +// `dst` at document pixel `d`. Dissolve takes the effect fully or not at all (effects::dissolves_in). +fn fx_composite(d: vec2, dst: vec4, s: vec4) -> vec4 { + if (op.mode == M_DISSOLVE) { + if (dissolve_noise(d) < s.a) { return composite(M_NORMAL, dst, vec4(s.rgb, 1.0), 1.0); } + return dst; + } + return composite(op.mode, dst, s, 1.0); +} + // Effect chain steps (`kind`): 0 A at `opacity` × alpha; 1 an opaque copy of A (a clipping base // for clipped layers' effects); 2 A's colour with alpha `opacity` inside B's shape (the layer // before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha); 4 the @@ -1136,7 +1189,7 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4 { let a = textureLoad(tex_b, p, 0).a; let s = textureLoad(tex_c, p, 0); if (s.a <= 0.0 || a <= INSIDE_EPS) { return c; } - return composite(op.mode, c, vec4(s.rgb, op.opacity * min(s.a / a, 1.0)), 1.0); + return fx_composite(doc_px(p), c, vec4(s.rgb, op.opacity * min(s.a / a, 1.0))); } case 5: { let a = textureLoad(tex_b, p, 0).a; @@ -1153,6 +1206,7 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4 { // beneath a layer of alpha `l` (disjoint areas). fn outline_share(cov: f32, l: f32) -> f32 { let outside = max(1.0 - clamp(cov, 0.0, 1.0), 0.0); + if (outside <= 1e-6) { return 0.0; } let rest = 1.0 - clamp(l, 0.0, 1.0); if (rest <= 1e-6) { return 1.0; } return min(outside / rest, 1.0); @@ -1185,12 +1239,12 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4 { let k = min(m * op.p0.x, 1.0) * op.opacity; if (k <= 0.0) { return dst; } let t = 1.0 - clamp(m, 0.0, 1.0); - return composite(op.mode, dst, vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t) * k), 1.0); + return fx_composite(d, dst, vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t) * k)); } let k = m * op.opacity; if (k <= 0.0) { return dst; } let c = fx_color(d); - return composite(op.mode, dst, vec4(c.rgb, c.a * k), 1.0); + return fx_composite(d, dst, vec4(c.rgb, c.a * k)); } // Outside strokes (A = the exterior result before them, B = the layer, C = coverage so far in @@ -1218,16 +1272,19 @@ fn fs_fxstroke(in: VOut) -> @location(0) vec4 { k = k * outline_share(a, lay_a); } else if (a > INSIDE_EPS) { // Pixel layers: the stroke shows beneath only through the part the shape leaves - // (`effects.rs`): (1 - a) / (1 - layer alpha), 1 under an opaque layer. + // (`effects.rs`): (1 - a) / (1 - layer alpha), none beneath a fully covered pixel. if (vector) { k = 0.0; - } else if (lay_a >= 1.0) { - k = 1.0; } else { - k = clamp((1.0 - clamp(a, 0.0, 1.0)) / (1.0 - lay_a), 0.0, 1.0); + k = k * outline_share(a, lay_a); } } - let share = k * op.opacity * (1.0 - cover); + var cov = k * op.opacity; + if (op.mode == M_DISSOLVE) { + // effects.rs: a Dissolve stroke covers each pixel fully or not at all. + cov = select(0.0, 1.0, cov > 0.0 && dissolve_noise(d) < cov); + } + let share = cov * (1.0 - cover); if (op.kind == 1) { return vec4(cover + share, lay_a, 0.0, 0.0); } var acc = vec4(0.0); if (!first) { acc = textureLoad(layer_tex, p, 0); } @@ -1280,7 +1337,14 @@ fn fs_fxmerge(in: VOut) -> @location(0) vec4 { let atop = (op.flags & F_ATOP) != 0u; var before = c; if (atop) { before = vec4(c.rgb, 1.0); } - let o = mix_premul(before, w, op.opacity); + var o: vec4; + if (op.mode == M_DISSOLVE) { + // Match the CPU effects compositor: opacity gates the completed stack, not + // its individual colours or alpha. Use document coordinates across chunks. + o = select(before, w, dissolve_noise(doc_px(p)) < op.opacity); + } else { + o = mix_premul(before, w, op.opacity); + } if (atop) { if (c.a > 0.0) { return vec4(o.rgb, c.a); } return c; @@ -1435,7 +1499,7 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4 { } // One bevel shading value (`bevel_maps`' shade_into): highlight (p1.z = 0) or shadow amount for -// height-gradient scale `depth`, times `region`, through the gloss contour. +// height-gradient scale `depth`, times `region`, with the shading through the gloss contour. fn bevel_part(p: vec2, depth: f32, region: f32) -> f32 { let gx = (ra(p + vec2(1, 0)) - ra(p - vec2(1, 0))) * 0.5 * depth; let gy = (ra(p + vec2(0, 1)) - ra(p - vec2(0, 1))) * 0.5 * depth; @@ -1443,14 +1507,15 @@ fn bevel_part(p: vec2, depth: f32, region: f32) -> f32 { let len = sqrt(n.x * n.x + n.y * n.y + 1.0); let shade = (n.x * op.p0.x + n.y * op.p0.y + n.z * op.p0.z) / len; let se = op.p0.w; - let k = shade - se; + var k = shade - se; + // effects::bevel_maps: the gloss contour maps the shading, a flat face's as the neutral (#7360). + if (op.p1.w > 0.5) { k = lut(0, shade) - lut(0, se); } var v = 0.0; if (op.p1.z > 0.5) { if (k <= 0.0) { v = clamp(-k / max(se, 1e-3), 0.0, 1.0) * region; } } else if (k > 0.0) { v = clamp(k / max(1.0 - se, 1e-3), 0.0, 1.0) * region; } - if (op.p1.w > 0.5) { v = lut(0, v); } return v; } diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index db204a3bf4..898eb2b87a 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -664,7 +664,23 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 { LayerContent::Shape(sh) => { std::mem::discriminant(&layer.content).hash(&mut h); if photocraft_compose::effect_outline(layer).is_some() { - format!("{:?}", sh.path).hash(&mut h); + // The cached maps live in region-local coordinates. Preserve their identity + // under translation so sync_fx can verify coverage and reuse them after a move. + let mut outline = + sh.path.control_bounds().map_or_else(|| sh.path.clone(), |(x, y, _, _)| sh.path.transform(&photocraft_geom::Affine::translate(-x, -y))); + // Integer translation can perturb fractional f64 coordinates in the low bits. + // Quantize only the fingerprint (1/65536 px); rendering keeps the original path, + // and sync_fx still requires exact coverage equality before reusing moved maps. + for subpath in &mut outline.subpaths { + for knot in &mut subpath.knots { + for point in [&mut knot.anchor, &mut knot.in_ctrl, &mut knot.out_ctrl] { + // Adding zero also canonicalizes negative zero in Debug output. + point.x = (point.x * 65536.0).round() / 65536.0 + 0.0; + point.y = (point.y * 65536.0).round() / 65536.0 + 0.0; + } + } + } + format!("{outline:?}").hash(&mut h); } } other => std::mem::discriminant(other).hash(&mut h), @@ -746,6 +762,42 @@ mod tests { use photocraft_color::BlendMode; use photocraft_doc::FxCommon; + /// Filled-shape identity survives integer moves, including fractional anchors and handles. + #[test] + fn filled_shape_key_preserves_translation_but_detects_deformation() { + use photocraft_doc::{Fill, Path, ShapeLayer, Subpath}; + let mut layer = Layer::new( + "shape", + LayerContent::Shape(ShapeLayer { + path: Path::new(vec![Subpath::polygon(&[(20.0, 20.0), (70.0, 20.0), (70.0, 60.0), (20.0, 60.0)])]), + fill: Some(Fill::Solid(photocraft_color::Color::WHITE)), + ..Default::default() + }), + ); + let canvas = Rect::new(0, 0, 200, 200); + let before = shape_key(&layer, canvas); + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path = shape.path.transform(&photocraft_geom::Affine::translate(13.0, 7.0)); + assert_eq!(before, shape_key(&layer, canvas)); + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path.subpaths[0].knots[1].anchor.x += 3.0; + assert_ne!(before, shape_key(&layer, canvas)); + + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path = Path::new(vec![Subpath::polygon(&[(0.1, 0.2), (0.3, 0.2), (0.3, 1.7), (0.1, 1.7)])]); + shape.path.subpaths[0].knots[1].out_ctrl = photocraft_geom::Point::new(0.45, 0.65); + let original = shape.path.clone(); + let before = shape_key(&layer, canvas); + for (dx, dy) in [(1.0, 1.0), (-13.0, 7.0), (100_000.0, -100_000.0)] { + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path = original.transform(&photocraft_geom::Affine::translate(dx, dy)); + assert_eq!(before, shape_key(&layer, canvas), "fractional path translated by {dx}, {dy}"); + } + let LayerContent::Shape(shape) = &mut layer.content else { unreachable!() }; + shape.path.subpaths[0].knots[1].out_ctrl.x += 0.01; + assert_ne!(before, shape_key(&layer, canvas), "a handle deformation invalidates the key"); + } + #[test] fn programs_key_on_geometry_not_colour() { let light = GlobalLight::default(); diff --git a/crates/gpu/src/health.rs b/crates/gpu/src/health.rs index 61aacf8b58..5c07fbc180 100644 --- a/crates/gpu/src/health.rs +++ b/crates/gpu/src/health.rs @@ -30,6 +30,14 @@ impl Fault { matches!(self, Fault::Lost(_)) } + /// An out-of-memory error on a device that still works: freeing memory and trying again can + /// succeed, unlike after a loss or a validation error (#6564). Only wgpu's own out-of-memory + /// error is recorded with exactly [`OUT_OF_MEMORY`]; a validation error that merely mentions + /// memory is not one. + pub fn is_out_of_memory(&self) -> bool { + matches!(self, Fault::Error(s) if s == OUT_OF_MEMORY) + } + /// The driver's or wgpu's message. pub fn detail(&self) -> &str { match self { @@ -48,6 +56,9 @@ impl fmt::Display for Fault { } } +/// The message an out-of-memory [`Fault::Error`] carries (wgpu's `Error::OutOfMemory`). +pub const OUT_OF_MEMORY: &str = "Out of Memory"; + #[derive(Default)] struct Inner { faulted: AtomicBool, @@ -99,19 +110,50 @@ impl DeviceHealth { self.0.errors.fetch_add(1, Ordering::Relaxed); let text = e.to_string(); log::error!("uncaptured wgpu error: {text}"); - // Errors about invalid or lost objects follow a device loss whose callback may not have - // run yet (it fires on the next poll). - let lower = text.to_ascii_lowercase(); - let fault = if lower.contains("lost") || lower.contains("destroyed") { Fault::Lost(first_line(&text)) } else { Fault::Error(first_line(&text)) }; - self.mark(fault); + self.mark(fault_of(e)); } - /// Mark the device unusable. The first fault is kept; a later device loss upgrades an error. + /// Record an error an error scope caught. + /// + /// [`DeviceHealth::watch`]'s uncaptured-error handler never sees a scoped error, so without + /// this a watched device would keep being handed to a GPU that has already failed. Polls + /// first: a lost device is a [`Fault::Lost`], and its callback may not have run yet (it fires + /// on a poll, and `Device::poll` panics on a lost device). + /// + /// The poll is a single non-blocking check, never a fence wait. This runs on the UI thread + /// immediately after a failed frame, so `PollType::Wait` with no timeout would stall the app + /// until every in-flight submission drained (or forever, if the device is wedged). A + /// non-blocking poll loses nothing here: wgpu still reads the fence and fires the + /// device-lost callback for a lost device, it just never blocks waiting for one. + /// + /// A device that only recorded an error is still polled and marked, so a loss or an + /// out-of-memory error can upgrade that fault as [`DeviceHealth::mark`] allows (#6564). + pub fn caught(&self, device: &wgpu::Device, error: &wgpu::Error) { + if self.lost() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + self.poll(device); + #[cfg(target_arch = "wasm32")] + let _ = device; + if self.lost() { + return; + } + self.mark(fault_of(error)); + } + + fn lost(&self) -> bool { + self.fault().is_some_and(|f| f.is_lost()) + } + + /// Mark the device unusable. The first fault is kept; a later device loss upgrades an error, + /// and a later out-of-memory error upgrades another one, so the canvas can still recover by + /// itself when memory ran out after a validation error in the same frame (#6564). pub fn mark(&self, fault: Fault) { let mut slot = self.0.fault.lock().unwrap_or_else(PoisonError::into_inner); let replace = match slot.as_ref() { None => true, - Some(old) => !old.is_lost() && fault.is_lost(), + Some(old) => !old.is_lost() && (fault.is_lost() || (fault.is_out_of_memory() && !old.is_out_of_memory())), }; if replace { *slot = Some(fault); @@ -169,16 +211,29 @@ impl DeviceHealth { /// wait). #[cfg(not(target_arch = "wasm32"))] pub fn wait(&self, device: &wgpu::Device, index: Option) -> bool { + self.wait_idle(device, index); + self.is_ok() + } + + /// Like [`Self::wait`], but returns whether the wait completed: `false` also when the poll + /// itself failed, so the work may still be running. + #[cfg(not(target_arch = "wasm32"))] + pub fn wait_idle(&self, device: &wgpu::Device, index: Option) -> bool { if !self.is_ok() { return false; } let poll = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Wait { submission_index: index, timeout: None }))); match poll { - Ok(Ok(_)) => {} - Ok(Err(e)) => log::warn!("wgpu poll: {e}"), - Err(_) => self.lost_by_poll(), + Ok(Ok(_)) => self.is_ok(), + Ok(Err(e)) => { + log::warn!("wgpu poll: {e}"); + false + } + Err(_) => { + self.lost_by_poll(); + false + } } - self.is_ok() } /// Non-blocking poll (reclaims staging memory), skipped once the device is unusable. @@ -192,6 +247,21 @@ impl DeviceHealth { } } +/// The fault a wgpu error stands for. Errors about invalid or lost objects follow a device loss +/// whose callback may not have run yet (it fires on the next poll), so they count as a loss +/// rather than as an error. Only wgpu's own out-of-memory error is [`OUT_OF_MEMORY`]. +fn fault_of(e: &wgpu::Error) -> Fault { + let text = e.to_string(); + let lower = text.to_ascii_lowercase(); + if matches!(e, wgpu::Error::OutOfMemory { .. }) { + Fault::Error(OUT_OF_MEMORY.into()) + } else if lower.contains("lost") || lower.contains("destroyed") { + Fault::Lost(first_line(&text)) + } else { + Fault::Error(first_line(&text)) + } +} + fn first_line(s: &str) -> String { let line = s.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""); let mut out: String = line.chars().take(200).collect(); @@ -229,5 +299,17 @@ mod tests { assert_eq!(Fault::Error("x".into()).to_string(), "GPU error: x"); assert_eq!(first_line("\n Buffer 'pc' is invalid\nmore"), "Buffer 'pc' is invalid"); assert_eq!(first_line(&"a".repeat(300)).chars().count(), 201); + assert!(Fault::Error("Out of Memory".into()).is_out_of_memory()); + assert!(!Fault::Error("Validation Error".into()).is_out_of_memory()); + assert!(!Fault::Error("Validation Error: buffer allocation would run out of memory".into()).is_out_of_memory()); + // A later out-of-memory error replaces another error, but never a loss. + let h = DeviceHealth::new(); + h.mark(Fault::Error("Validation Error".into())); + h.mark(Fault::Error(OUT_OF_MEMORY.into())); + assert!(h.fault().is_some_and(|f| f.is_out_of_memory())); + h.mark(Fault::Lost("reset".into())); + h.mark(Fault::Error(OUT_OF_MEMORY.into())); + assert!(h.fault().is_some_and(|f| f.is_lost())); + assert!(!Fault::Lost("out of memory".into()).is_out_of_memory()); } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 4d3fd8f93a..7770662b15 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -34,10 +34,14 @@ //! (Multichannel documents, patterns larger than the texture limit, uncached gradients with //! coincident colour or opacity stops) returns [`Unsupported`]; //! callers fall back to the CPU compositor. +//! +//! [`CameraRawDevelop`] (`camera_raw.rs`) is a separate pass, not part of the compositor: Camera +//! Raw's per-pixel develop stages for the dialog's preview, built the first time the dialog opens. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod bounds; +mod camera_raw; mod fx; pub mod health; pub mod plan; @@ -54,6 +58,7 @@ use photocraft_doc::{DocId, Document, LayerContent, LayerId, Pattern}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; use photocraft_raster::{Surface, Tile}; +pub use camera_raw::{CAMERA_RAW_SHADER, CameraRawDevelop}; pub use health::{DeviceHealth, Fault}; pub use plan::{Kernel, Plan, Role, Unsupported, plan}; pub use timing::{GpuTimer, GpuTiming}; @@ -468,6 +473,12 @@ pub struct Compositor { resident_budget: u64, /// GPU memory the effect-map cache may hold ([`FX_BUDGET`] or less). fx_budget: u64, + /// Layer-page bytes the last full fit check asked for ([`Compositor::fit_layers`]). + layer_need: u64, + /// Bytes of `fx_budget` lent to `resident_budget` so those pages fit. + lent: u64, + /// `resident_budget` was set exactly ([`Compositor::set_resident_budget`]): nothing is lent. + exact_resident: bool, /// What the view shows: a full refresh draws these cells last, so their pages are the /// ones still resident for the edits that follow. focus: Option, @@ -482,6 +493,9 @@ pub struct Compositor { layer_alloc: Option, /// Bytes of pages dropped to admit new ones, not yet counted towards an intermediate submit. admit_freed: u64, + /// Test hook ([`Compositor::fail_layer_allocs`]): how many more layer page textures fail as + /// out of memory. + fail_allocs: u32, acc_format: wgpu::TextureFormat, /// Whether the effect-map pipelines could be built (else documents with layer effects are /// [`Unsupported`] and use the CPU compositor). @@ -613,8 +627,8 @@ fn lru_victims(mut pages: Vec<(u64, K, u64)>, total: u64, incoming: u64, budg } /// Runs `f` inside GPU error scopes and returns the first error raised. -/// Native wgpu reports errors synchronously; where they only arrive asynchronously (WebGPU) none -/// is reported. +/// Native wgpu reports validation/internal/OOM errors synchronously; where they only arrive +/// asynchronously (WebGPU) none is reported. fn first_error(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option) { let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); @@ -631,17 +645,30 @@ fn first_error(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option(device: &wgpu::Device, watched: bool, f: impl FnOnce() -> Result) -> Result { +/// Runs `f` inside the error scopes of [`first_error`], turning a reported error into +/// [`Unsupported`] instead of letting it escape to wgpu's panic handler. +/// +/// Every native caller needs this, watched or not: a validation error (writing into a destroyed +/// or too-small buffer, say) is raised synchronously and never reaches the health callbacks, so a +/// watched compositor still panicked on it. WebGPU reports its errors asynchronously, long after +/// the scope has popped, so wasm relies on the health callbacks alone. +/// +/// A scope also swallows the error that [`DeviceHealth`]'s uncaptured-error handler would have +/// recorded, so a reported error is handed back to `health`: a watched device must stop being +/// used and let the caller fall back to the CPU compositor. +fn scoped_work(device: &wgpu::Device, health: Option, f: impl FnOnce() -> Result) -> Result { // WebGPU errors arrive asynchronously; its callers still use the health callbacks. - if watched || cfg!(target_arch = "wasm32") { + if cfg!(target_arch = "wasm32") { return f(); } let (result, error) = first_error(device, f); match error { - Some(error) => Err(Unsupported(format!("GPU work failed: {error}"))), + Some(error) => { + if let Some(health) = health.as_ref() { + health.caught(device, &error); + } + Err(Unsupported(format!("GPU work failed: {error}"))) + } None => result, } } @@ -665,9 +692,9 @@ impl Compositor { /// to the CPU compositor. Where errors only arrive asynchronously (WebGPU), creation is assumed /// to have worked. pub fn try_new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Result { - match first_error(device, || Self::new_with_format(device, acc_format)) { - (comp, None) => Ok(comp), - (_, Some(e)) => Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}"))), + match first_error(device, || Self::build(device, acc_format)) { + ((comp, None), None) => Ok(comp), + ((_, Some(e)), _) | (_, Some(e)) => Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}"))), } } @@ -675,6 +702,18 @@ impl Compositor { /// `Rgba16Float` on adapters that can't render to `Rgba32Float` (e.g. some Intel Vulkan drivers), /// which otherwise panics at render-pipeline creation. See `Compositor::preferred_acc_format`. pub fn new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Self { + let (comp, error) = Self::build(device, acc_format); + if let Some(e) = error { + log::error!("GPU compositor: couldn't build its pipelines: {e}"); + } + comp + } + + /// The compositor and the first error raised building its (non-map) pipelines. They are + /// 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). + fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> (Self, Option) { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_compose"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); let bgl0 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_compose_uniforms"), @@ -709,19 +748,42 @@ impl Compositor { cache: None, })) }; - let mut pipelines: HashMap<_, _> = - Kernel::DRAWN.iter().filter(|k| !k.is_map()).filter_map(|&k| Some(((k, acc_format), pipeline(k, acc_format)?))).collect(); // 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 maps = Kernel::DRAWN.iter().filter(|k| k.is_map()).flat_map(|&k| [(k, MAP32), (k, MAP16)]); - let (maps, map_error) = first_error(device, || maps.filter_map(|key| Some((key, pipeline(key.0, key.1)?))).collect::>()); + 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 (mut pipelines, mut maps) = (HashMap::new(), Vec::new()); + let (mut error, mut map_error) = (None, None); + for (key, built, e) in built { + let map = key.0.is_map(); + // A failed map pipeline turns all effect maps off below. A failed non-map pipeline + // keeps its (invalid) handle: draws look pipelines up by key, so dropping it would + // turn a validation error into a missing-key panic on the first draw. + if let Some(e) = e { + if map { + map_error.get_or_insert(e); + continue; + } + error.get_or_insert(e); + } + let Some(p) = built else { continue }; + if map { + maps.push((key, p)); + } else { + pipelines.insert(key, p); + } + } if let Some(e) = &map_error { log::info!("GPU compositor: no effect-map pipelines ({e}); layer effects use the CPU"); } else { pipelines.extend(maps); } let dummy = Tex::new(device, "pc_compose_dummy", 1, 1, wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureUsages::TEXTURE_BINDING).view; - Self { + let comp = Self { kit: Kit { pipelines, bgl0, bgl1, dummy }, pool: Vec::new(), residents: HashMap::new(), @@ -738,6 +800,9 @@ impl Compositor { chunk: CHUNK, resident_budget: RESIDENT_BUDGET, fx_budget: (FX_BUDGET as u64).saturating_sub(prefix::MAX_BYTES), + layer_need: 0, + lent: 0, + exact_resident: false, focus: None, frame: 0, stamp: 0, @@ -745,13 +810,15 @@ impl Compositor { recorded: 0, layer_alloc: None, admit_freed: 0, + fail_allocs: 0, acc_format, effect_maps: map_error.is_none(), cmyk: 0, health: None, timer: None, } - .with_texture_limit(device.limits().max_texture_dimension_2d) + .with_texture_limit(device.limits().max_texture_dimension_2d); + (comp, error) } fn with_texture_limit(mut self, max: u32) -> Self { @@ -784,9 +851,21 @@ impl Compositor { self.page } - /// Set the GPU memory resident layer pages may hold (default [`RESIDENT_BUDGET`]). + /// Test hook: the next `n` layer page textures fail as if the device were out of memory + /// (a real one can't be forced), so the fallback paths of #6563 can be tested. + #[doc(hidden)] + pub fn fail_layer_allocs(&mut self, n: u32) { + self.fail_allocs = n; + } + + /// Set the GPU memory resident layer pages may hold (default [`RESIDENT_BUDGET`]), exactly: + /// nothing is lent to it ([`Self::fit_layers`]) until the next [`Self::set_memory_budget`]. pub fn set_resident_budget(&mut self, bytes: u64) { + // The loan goes back to the effect maps, so the total budget stays what it was. + self.fx_budget = self.fx_budget.saturating_add(self.lent); + self.lent = 0; self.resident_budget = bytes; + self.exact_resident = true; } /// Set the GPU memory the compositor may hold for layer pages and caches together @@ -797,6 +876,36 @@ impl Compositor { self.prefix.set_budget((caches / 2).min(prefix::MAX_BYTES)); self.fx_budget = caches.saturating_sub(self.prefix.budget()); self.resident_budget = bytes.saturating_sub(caches); + self.lent = 0; + self.exact_resident = false; + self.lend(); + } + + /// Record that a render needs `needed` bytes of layer pages and return the resident budget + /// they now have. A render of the `whole` document sets the need; a part of it only raises + /// it, so a large partial refresh never shrinks what the next full one keeps resident. + /// + /// Pages that miss their share by less than the effect-map share borrow from it: an evicted + /// effect map is rebuilt by the next refresh that draws it, but one page too many sends the + /// whole refresh to the CPU compositor, 10× slower (#5973, P60: 150 layers of 6 MP on an + /// integrated GPU missed by 5 % and took 680 ms instead of 62). + /// The lower-stack share is never lent: it is what keeps a mid-stack edit at half the work. + /// The loan holds across [`Self::set_memory_budget`] until a later call needs less or more. + pub fn fit_layers(&mut self, needed: u64, whole: bool) -> u64 { + self.layer_need = if whole { needed } else { self.layer_need.max(needed) }; + self.lend(); + self.resident_budget + } + + /// Return the loan to the effect maps, then lend what [`Self::layer_need`] still lacks, if + /// the effect-map share covers it (lending part of it would fit nothing and only evict maps). + fn lend(&mut self) { + self.fx_budget = self.fx_budget.saturating_add(self.lent); + self.resident_budget = self.resident_budget.saturating_sub(self.lent); + let short = self.layer_need.saturating_sub(self.resident_budget); + self.lent = if short <= self.fx_budget && !self.exact_resident { short } else { 0 }; + self.fx_budget = self.fx_budget.saturating_sub(self.lent); + self.resident_budget = self.resident_budget.saturating_add(self.lent); } /// The budget of layer pages, effect maps and lower-stack chunks (bytes). @@ -1050,7 +1159,7 @@ impl Compositor { region: Rect, mut sink: impl FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), ) -> Result { - let result = scoped_work(device, self.health.is_some(), || { + let result = scoped_work(device, self.health.clone(), || { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); if self.timer.as_ref().is_some_and(timing::GpuTimer::busy) { // The readback map completes on a poll. The app polls every frame, but a @@ -1111,7 +1220,7 @@ impl Compositor { region: Rect, sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), ) -> Result { - let result = scoped_work(device, self.health.is_some(), || self.encode_inner(device, queue, encoder, doc, region, sink, false)); + let result = scoped_work(device, self.health.clone(), || self.encode_inner(device, queue, encoder, doc, region, sink, false)); if result.is_err() { self.discard_failed_work(); } @@ -1260,12 +1369,15 @@ impl Compositor { self.layer_alloc = None; submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit); #[cfg(not(target_arch = "wasm32"))] - match self.health.as_ref() { - Some(h) => { - h.wait(device, last_submit.clone()); - } - None => { - let _ = device.poll(wgpu::PollType::Wait { submission_index: last_submit.clone(), timeout: None }); + { + let idle = match self.health.as_ref() { + Some(h) => h.wait_idle(device, last_submit.clone()), + None => device.poll(wgpu::PollType::Wait { submission_index: last_submit.clone(), timeout: None }).is_ok(), + }; + // The GPU has run every timestamp submitted so far, so a retry that fails too + // can drop this measurement without switching timing off for good. + if idle && let Some(t) = self.timer.as_mut() { + t.drained(); } } self.staged = 0; @@ -1408,7 +1520,11 @@ impl Compositor { return Some(device.create_texture(desc)); } for retry in [false, true] { - let (texture, error) = first_error(device, || device.create_texture(desc)); + let (texture, mut error) = first_error(device, || device.create_texture(desc)); + if error.is_none() && self.fail_allocs > 0 { + self.fail_allocs -= 1; + error = Some(wgpu::Error::OutOfMemory { source: "out of memory (test hook)".into() }); + } match error { None => return Some(texture), Some(wgpu::Error::OutOfMemory { .. }) if !retry => { diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 6cb44b9200..38cc879b09 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -361,8 +361,15 @@ struct Planner<'a> { cx: DocCtx<'a>, fx: Vec>, checkpoints: Vec, + /// Layers planned so far, counting a layer each time it is planned (a clipped pass-through + /// group is planned twice). + visits: usize, } +/// Most layer plannings one document may take: a pass-through group with clipped layers is +/// planned twice, so groups nested in one another double the work at every level. +const MAX_PLAN_VISITS: usize = 100_000; + /// Coverage source of an effect paint (`kind` in `fs_fxpaint`); flags refine it. #[derive(Clone, Copy)] enum Cov { @@ -455,7 +462,7 @@ pub const SHAPE_MAP: usize = usize::MAX; impl<'a> Planner<'a> { fn new(cx: DocCtx<'a>) -> Self { - Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new(), checkpoints: Vec::new() } + Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new(), checkpoints: Vec::new(), visits: 0 } } fn finish(self, root: Slot) -> Plan<'a> { @@ -607,6 +614,10 @@ impl<'a> Planner<'a> { /// composite_layer, honouring the layer's channel restrictions. fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result { + self.visits += 1; + if self.visits > MAX_PLAN_VISITS { + return Err(Unsupported("too many nested layers to plan for the GPU (composited on the CPU)".into())); + } self.check_advanced(layer, clipped)?; self.restricted(layer, backdrop, |s, b| s.layer_any(layer, clipped, b)) } @@ -1016,7 +1027,7 @@ impl<'a> Planner<'a> { } let mut p = Pass::new(Kernel::Adjust, 0); p.a = Some(src); - let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, self.cx.depth); + let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, self.cx.depth, self.cx.mode); p.adjust_kind = kind; p.params = params; p.lut = lut; @@ -1329,7 +1340,9 @@ impl<'a> Planner<'a> { p.c = cover.map(|c| self.retain(c)); p.d = acc; let a = self.emit(p); - let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), BlendMode::Normal, st.common.opacity, flags, clip, sb); + // The stroke's mode rides along only for Dissolve, whose coverage is all or + // nothing in this pass as in the colour pass (#6995). + let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), st.common.blend, st.common.opacity, flags, clip, sb); p.adjust_kind = 1; p.map = Some(map(i, 0)); p.c = cover; @@ -1643,7 +1656,7 @@ fn transfer_exponent(t: Transfer) -> f32 { } /// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`. -pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType) -> Program { +pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType, mode: photocraft_color::ColorMode) -> Program { let mut p = [[0.0f32; 4]; 4]; match adj { Adjustment::Invert => (1, p, None), @@ -1698,10 +1711,13 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf for (v, identity) in p[0].iter_mut().zip(adjust::float_tone_identity(adj, Some(depth))) { *v = if identity { 1.0 } else { 0.0 }; } + // p0.w: continue the curve past 0..1 (32-bit RGB Curves, #7157). + p[0][3] = if adjust::float_tone_extrapolates(adj, Some(depth), mode) { 1.0 } else { 0.0 }; (7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect())) } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { - p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }]; + let sat = adjust::colorize_saturation(saturation / 100.0, lightness / 100.0, *colorize, Some(depth)); + p[0] = [*hue, sat, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }]; if !*colorize && ranges.iter().any(|r| !r.is_neutral()) { // Range edits: hue-indexed rows (shift, saturation, lightness), as on the CPU. p[1][0] = 1.0; @@ -1757,9 +1773,9 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf (13, p, Some(rows)) } Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { - // Per-channel curves (compose::adjust::color_balance_channel), through the Levels kernel. - let tones = [*shadows, *midtones, *highlights]; - (7, p, Some(lut_rows(|ch, v| adjust::color_balance_channel(tones, *preserve_luminosity, ch, v), 3))) + // Per-channel curves (compose::adjust::BalanceCurve), through the Levels kernel. + let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + (7, p, Some(lut_rows(|ch, v| curves[ch].eval(v), 3))) } Adjustment::SelectiveColor { relative, adjustments } => { // 9 ranges × CMYK don't fit the 16 params: they go in LUT row 0 (read texel-exact). @@ -1873,6 +1889,23 @@ mod tests { use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_geom::Size; + #[test] + fn nested_pass_through_groups_with_clipped_layers_are_not_planned_exponentially() { + let mut layers = vec![Layer::raster("leaf", photocraft_color::PixelFormat::RGBA8)]; + for _ in 0..30 { + let group = Layer::group("g", layers); + let mut clipped = Layer::raster("c", photocraft_color::PixelFormat::RGBA8); + clipped.clipped = true; + layers = vec![group, clipped]; + } + let mut doc = Document::new("nest", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + doc.layers = layers; + let started = std::time::Instant::now(); + let Err(error) = plan(&doc) else { panic!("2^30 plannings must be refused") }; + assert!(error.0.contains("too many nested layers"), "{}", error.0); + assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed()); + } + #[test] fn stops_closer_than_one_lut_step_stay_on_the_cpu() { assert!(check_gradient_stops(&[(0.4999_f32, ()), (0.5, ())]).is_err()); @@ -1965,7 +1998,7 @@ mod tests { fn color_lookup_with_an_overflowing_stored_size_uploads_no_table() { for size in [1 << 22, u32::MAX] { let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false }; - let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8); + let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8, photocraft_color::ColorMode::Rgb); assert_eq!((kind, rows.is_none()), (0, true), "size {size}"); } } diff --git a/crates/gpu/src/timing.rs b/crates/gpu/src/timing.rs index e79548d84c..0f072bfc3e 100644 --- a/crates/gpu/src/timing.rs +++ b/crates/gpu/src/timing.rs @@ -95,8 +95,9 @@ pub struct GpuTimer { /// A recording was abandoned after it had already submitted timestamps. Writing a query /// again resets that slot (wgpu-core resets per write), and resetting one an in-flight /// submission is still writing is undefined, with no way to learn when it has drained. - /// That only happens when a refresh fails after a flush, which on this path means the - /// device faulted and the canvas is about to drop to the CPU compositor anyway, so + /// That only happens when a refresh fails after a flush that nothing waited for (a refresh + /// whose page retry waited for the GPU calls [`Self::drained`] first), which is rare: usually + /// the device faulted and the canvas is about to drop to the CPU compositor anyway. So /// measuring simply stops: no number, which is the contract everywhere else here. stopped: bool, /// Timestamps and pixels of the measurement being read back, and its readback flag. @@ -236,6 +237,12 @@ impl GpuTimer { self.flushed = false; } + /// The caller waited until the GPU finished everything submitted so far: no timestamp of + /// this recording is still in flight, so [`Self::cancel`] can reuse its slots safely. + pub fn drained(&mut self) { + self.flushed = false; + } + /// Whether a measurement is being read back (so this refresh won't be measured). pub fn busy(&self) -> bool { self.in_flight.is_some() diff --git a/crates/gpu/tests/it/camera_raw.rs b/crates/gpu/tests/it/camera_raw.rs new file mode 100644 index 0000000000..ff841c825a --- /dev/null +++ b/crates/gpu/tests/it/camera_raw.rs @@ -0,0 +1,370 @@ +//! Camera Raw's GPU preview pass against the CPU develop (the oracle), stage by stage, at 8, 16 +//! and 32 bits. Skips, with a message, when there is no adapter or device or it can't render +//! `Rgba32Float`; with `WGPU_BACKEND` set those fail instead, as in `parity.rs`. A pipeline that +//! doesn't build on an adapter that can run it fails. The shader itself is validated without a +//! GPU ([`the_shader_validates_without_a_gpu`]), so a WGSL error fails on every runner. + +use photocraft_algo::camera_raw::{CameraRaw, Wheel, develop, per_pixel, white_black_points}; +use photocraft_gpu::{CameraRawDevelop, DeviceHealth, Fault}; + +/// Largest difference from the CPU develop, per channel. The pass evaluates the same +/// expressions in f32, so what remains is the GPU's own `pow`, `exp2` and `sin` (a few ulps) and +/// how they move a value across a table entry; measured at most about 1e-5 on NVIDIA (Vulkan, +/// DX12) and WARP. 1/4096 keeps a margin and stays well under one 8-bit step (the compositor's +/// tolerance is 1/255). +const TOL: f32 = 1.0 / 4096.0; + +struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, + dev: CameraRawDevelop, + _lock: std::sync::MutexGuard<'static, ()>, +} + +fn gpu() -> Option { + let lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let required = std::env::var_os("WGPU_BACKEND").is_some(); + // `WGPU_BACKEND` picks the backend; `PHOTOCRAFT_TEST_FALLBACK_ADAPTER=1` the software one (WARP). + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let fallback = std::env::var_os("PHOTOCRAFT_TEST_FALLBACK_ADAPTER").is_some(); + let options = wgpu::RequestAdapterOptions { force_fallback_adapter: fallback, ..Default::default() }; + let adapter = match pollster::block_on(instance.request_adapter(&options)) { + Ok(a) => a, + Err(e) => { + assert!(!required, "WGPU_BACKEND is set but has no adapter: {e}"); + eprintln!("skipping Camera Raw GPU tests: no adapter ({e})"); + return None; + } + }; + let info = adapter.get_info(); + if !CameraRawDevelop::supported(&adapter) { + assert!(!required, "WGPU_BACKEND is set but {} can't render Rgba32Float", info.name); + eprintln!("skipping Camera Raw GPU tests: adapter can't render Rgba32Float"); + return None; + } + let (device, queue) = match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) { + Ok(d) => d, + Err(e) => { + assert!(!required, "WGPU_BACKEND is set but {} has no device: {e}", info.name); + eprintln!("skipping Camera Raw GPU tests: no device ({e})"); + return None; + } + }; + eprintln!("Camera Raw GPU tests on {:?} ({})", info.backend, info.name); + // The adapter can run the pass: a pipeline that doesn't build is a bug in it, not a skip. + let dev = CameraRawDevelop::new(&device).unwrap_or_else(|e| panic!("the Camera Raw pipeline didn't build on {}: {}", info.name, e.0)); + Some(Gpu { device, queue, dev, _lock: lock }) +} + +/// The Camera Raw shader parses and validates (naga, as wgpu does before any driver sees it), +/// with no adapter: the GPU tests below skip on runners without one. +#[test] +fn the_shader_validates_without_a_gpu() { + let src = photocraft_gpu::CAMERA_RAW_SHADER; + 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.emit_to_string(src))); + assert!(!module.entry_points.is_empty()); +} + +#[derive(Clone, Copy, Debug)] +enum Depth { + Eight, + Sixteen, + Float, +} + +const DEPTHS: [Depth; 3] = [Depth::Eight, Depth::Sixteen, Depth::Float]; + +fn hash(i: usize, c: u64) -> f32 { + let mut z = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ c.wrapping_mul(0xC2B2_AE3D_27D4_EB4F); + z = (z ^ (z >> 29)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + (z >> 40) as f32 / (1u64 << 24) as f32 +} + +/// Gradients, noise and saturated patches (every Color Mixer band), quantized to `depth`. The +/// 32-bit image also has values above 1 and a few below 0. +fn image(w: usize, h: usize, depth: Depth) -> Vec<[f32; 4]> { + (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32); + let mut c = if (i / w).is_multiple_of(4) { + // A hue sweep at full and half saturation. + let hue = x * 6.0; + let f = |n: f32| (((hue + n) % 6.0 - 3.0).abs() - 1.0).clamp(0.0, 1.0); + let s = if (i / w).is_multiple_of(8) { 1.0 } else { 0.5 }; + [f(0.0), f(4.0), f(2.0)].map(|v| (1.0 - s) * 0.6 + s * v) + } else { + [0.1 + 0.8 * x, 0.2 + 0.6 * y, 0.5 - 0.4 * x * y].map(|v| v + (hash(i, 1) - 0.5) * 0.15) + }; + if let Depth::Float = depth { + c = c.map(|v| v * (1.0 + 1.5 * y) - 0.02 * hash(i, 2)); + } + let q = |v: f32| match depth { + Depth::Eight => (v.clamp(0.0, 1.0) * 255.0).round() / 255.0, + Depth::Sixteen => (v.clamp(0.0, 1.0) * 32768.0).round() / 32768.0, + Depth::Float => v, + }; + [q(c[0]), q(c[1]), q(c[2]), 1.0] + }) + .collect() +} + +/// Develops `p` both ways and returns the largest channel difference. +fn compare(g: &mut Gpu, p: &CameraRaw, depth: Depth) -> f32 { + let (w, h) = (171, 113); + let float = matches!(depth, Depth::Float); + let src = image(w, h, depth); + let mut cpu = src.clone(); + develop(&mut cpu, w, h, p, float); + let points = white_black_points(&src, w, h, p); + let stages = per_pixel(p, w, h, float, points).unwrap_or_else(|| panic!("{p:?} has a spatial stage")); + let out = g.dev.develop(&g.device, &g.queue, None, &src, None, [w as u32, h as u32], &stages).unwrap(); + assert_eq!(out.len(), cpu.len()); + let mut worst = (0.0f32, 0usize); + for (i, (a, b)) in out.iter().zip(&cpu).enumerate() { + assert!(a.iter().all(|v| v.is_finite()), "{p:?} {depth:?}: non-finite GPU pixel {i}: {a:?}"); + assert_eq!(a[3], b[3], "alpha is kept"); + for c in 0..3 { + let d = (a[c] - b[c]).abs(); + if d > worst.0 { + worst = (d, i); + } + } + } + let (d, i) = worst; + assert!(d <= TOL, "{p:?} {depth:?}: GPU {:?} vs CPU {:?} at pixel {i} (source {:?}), off by {d}", out[i], cpu[i], src[i]); + if !float { + assert!(out.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v))), "{p:?} {depth:?}: clipped like the CPU"); + } + d +} + +fn check(name: &str, sets: &[CameraRaw]) { + let Some(mut g) = gpu() else { return }; + for p in sets { + for depth in DEPTHS { + let d = compare(&mut g, p, depth); + eprintln!("{name} {depth:?}: max |GPU - CPU| = {d:.2e}"); + } + } +} + +#[test] +fn white_balance_and_exposure() { + check( + "white balance", + &[ + CameraRaw { temperature: 40.0, tint: -25.0, ..Default::default() }, + CameraRaw { temperature: -70.0, tint: 60.0, exposure: -1.2, ..Default::default() }, + CameraRaw { exposure: 1.5, ..Default::default() }, + ], + ); +} + +#[test] +fn contrast_without_texture_or_clarity() { + check("contrast", &[CameraRaw { contrast: 60.0, ..Default::default() }, CameraRaw { contrast: -80.0, exposure: 0.4, ..Default::default() }]); +} + +#[test] +fn whites_and_blacks() { + check( + "whites/blacks", + &[ + CameraRaw { whites: 70.0, blacks: -45.0, ..Default::default() }, + CameraRaw { whites: -60.0, blacks: 50.0, temperature: 20.0, ..Default::default() }, + CameraRaw { whites: 30.0, ..Default::default() }, + CameraRaw { blacks: -100.0, exposure: -0.5, ..Default::default() }, + // ProPhoto's power-law encoding. + CameraRaw { whites: 50.0, blacks: -30.0, encoding_gamma: 1.8, ..Default::default() }, + ], + ); +} + +#[test] +fn tone_curves() { + check( + "tone curves", + &[ + CameraRaw { + curve_highlights: -60.0, + curve_lights: 30.0, + curve_darks: 40.0, + curve_shadows: -20.0, + curve_splits: [20.0, 45.0, 80.0], + ..Default::default() + }, + CameraRaw { point_curve: vec![[0.0, 20.0], [64.0, 50.0], [192.0, 220.0], [255.0, 245.0]], ..Default::default() }, + CameraRaw { + point_curve_red: vec![[0.0, 0.0], [128.0, 160.0], [255.0, 255.0]], + point_curve_green: vec![[0.0, 10.0], [255.0, 230.0]], + point_curve_blue: vec![[0.0, 0.0], [100.0, 70.0], [255.0, 255.0]], + ..Default::default() + }, + ], + ); +} + +#[test] +fn vibrance_and_saturation() { + check( + "presence", + &[ + CameraRaw { vibrance: 70.0, ..Default::default() }, + CameraRaw { saturation: -60.0, ..Default::default() }, + CameraRaw { vibrance: -40.0, saturation: 50.0, ..Default::default() }, + ], + ); +} + +#[test] +fn color_mixer() { + let ramp = |k: f32| std::array::from_fn(|i| ((i as f32 * 37.0 + k) % 200.0) - 100.0); + check( + "color mixer", + &[ + CameraRaw { hsl_hue: ramp(0.0), ..Default::default() }, + CameraRaw { hsl_sat: ramp(50.0), ..Default::default() }, + CameraRaw { hsl_lum: ramp(120.0), ..Default::default() }, + CameraRaw { hsl_hue: ramp(10.0), hsl_sat: ramp(80.0), hsl_lum: ramp(150.0), ..Default::default() }, + ], + ); +} + +#[test] +fn color_grading() { + check( + "color grading", + &[ + CameraRaw { grade_shadows: Wheel { hue: 220.0, sat: 70.0, lum: 20.0 }, ..Default::default() }, + CameraRaw { + grade_midtones: Wheel { hue: 120.0, sat: 30.0, lum: -15.0 }, + grade_highlights: Wheel { hue: 40.0, sat: 50.0, lum: 10.0 }, + grade_global: Wheel { hue: 300.0, sat: 15.0, lum: 5.0 }, + grade_blending: 80.0, + grade_balance: -40.0, + ..Default::default() + }, + ], + ); +} + +#[test] +fn vignette() { + check( + "vignette", + &[ + CameraRaw { vignette_amount: -70.0, ..Default::default() }, + CameraRaw { vignette_amount: -50.0, vignette_roundness: -60.0, vignette_feather: 20.0, vignette_highlights: 70.0, ..Default::default() }, + CameraRaw { + vignette_amount: 60.0, + vignette_roundness: 80.0, + vignette_midpoint: 20.0, + vignette_style: "colorPriority".into(), + ..Default::default() + }, + CameraRaw { vignette_amount: -40.0, vignette_style: "paintOverlay".into(), ..Default::default() }, + CameraRaw { vignette_amount: 40.0, vignette_style: "paintOverlay".into(), ..Default::default() }, + ], + ); +} + +#[test] +fn grain_in_full_resolution_coordinates() { + check( + "grain", + &[ + CameraRaw { grain_amount: 60.0, seed: 3, ..Default::default() }, + // A preview proxy: the noise lattice is sampled at full-resolution positions. + CameraRaw { grain_amount: 80.0, grain_size: 60.0, grain_roughness: 90.0, seed: u32::MAX, pixel_scale: 0.143, ..Default::default() }, + CameraRaw { grain_amount: 25.0, grain_size: 0.0, grain_roughness: 0.0, seed: 77, pixel_scale: 0.5, ..Default::default() }, + ], + ); +} + +#[test] +fn every_per_pixel_stage_at_once() { + let mut hsl = [0.0; 8]; + hsl[1] = 35.0; + hsl[5] = -50.0; + check( + "all stages", + &[CameraRaw { + temperature: 25.0, + tint: 10.0, + exposure: 0.3, + contrast: 25.0, + whites: 20.0, + blacks: -15.0, + vibrance: 30.0, + saturation: 10.0, + curve_darks: 20.0, + point_curve: vec![[0.0, 8.0], [255.0, 250.0]], + hsl_hue: hsl, + hsl_sat: hsl, + hsl_lum: hsl, + grade_shadows: Wheel { hue: 210.0, sat: 25.0, lum: 0.0 }, + vignette_amount: -30.0, + grain_amount: 20.0, + pixel_scale: 0.2, + ..Default::default() + }], + ); +} + +#[test] +fn identity_leaves_pixels_alone_and_spatial_settings_have_no_pass() { + let Some(mut g) = gpu() else { return }; + let p = CameraRaw::default(); + let src = image(64, 32, Depth::Float); + let stages = per_pixel(&p, 64, 32, false, None).unwrap(); + let out = g.dev.develop(&g.device, &g.queue, None, &src, Some(1), [64, 32], &stages).unwrap(); + assert_eq!(out, src, "the CPU develop returns early, unclipped"); + for spatial in [ + CameraRaw { dehaze: 10.0, ..Default::default() }, + CameraRaw { shadows: 10.0, ..Default::default() }, + CameraRaw { highlights: -10.0, ..Default::default() }, + CameraRaw { texture: 10.0, ..Default::default() }, + CameraRaw { clarity: 10.0, ..Default::default() }, + CameraRaw { sharpen_amount: 10.0, ..Default::default() }, + CameraRaw { noise_luminance: 10.0, ..Default::default() }, + CameraRaw { noise_color: 10.0, ..Default::default() }, + ] { + assert!(spatial.spatial() && per_pixel(&spatial, 64, 32, false, Some((0.0, 1.0))).is_none(), "{spatial:?}"); + } + // Whites and Blacks need the image's points. + assert!(per_pixel(&CameraRaw { whites: 10.0, ..Default::default() }, 64, 32, false, None).is_none()); +} + +#[test] +fn a_cached_source_is_reused_and_a_new_key_uploads() { + let Some(mut g) = gpu() else { return }; + let p = CameraRaw { exposure: 0.5, ..Default::default() }; + let stages = per_pixel(&p, 64, 32, false, None).unwrap(); + let a = image(64, 32, Depth::Eight); + let b: Vec<[f32; 4]> = a.iter().map(|q| [q[2], q[0], q[1], 1.0]).collect(); + let first = g.dev.develop(&g.device, &g.queue, None, &a, Some(7), [64, 32], &stages).unwrap(); + // Same key: the uploaded source stays (callers key by the proxy's identity). + assert_eq!(g.dev.develop(&g.device, &g.queue, None, &b, Some(7), [64, 32], &stages).unwrap(), first); + let mut cpu = b.clone(); + develop(&mut cpu, 64, 32, &p, false); + let other = g.dev.develop(&g.device, &g.queue, None, &b, Some(8), [64, 32], &stages).unwrap(); + assert!(other.iter().zip(&cpu).all(|(x, y)| (0..3).all(|c| (x[c] - y[c]).abs() <= TOL))); +} + +#[test] +fn bad_sizes_and_faulted_devices_are_errors() { + let Some(mut g) = gpu() else { return }; + let stages = per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 4, 4, false, None).unwrap(); + let px = vec![[0.5f32; 4]; 16]; + for size in [[0, 4], [4, 0], [5, 4], [3, 3], [u32::MAX, 1]] { + assert!(g.dev.develop(&g.device, &g.queue, None, &px, None, size, &stages).is_err(), "{size:?}"); + } + let health = DeviceHealth::new(); + health.mark(Fault::Lost("test".into())); + assert!(g.dev.develop(&g.device, &g.queue, Some(&health), &px, None, [4, 4], &stages).is_err()); + // And the pass still works afterwards. + assert!(g.dev.develop(&g.device, &g.queue, Some(&DeviceHealth::new()), &px, None, [4, 4], &stages).is_ok()); +} diff --git a/crates/gpu/tests/device_loss.rs b/crates/gpu/tests/it/device_loss.rs similarity index 88% rename from crates/gpu/tests/device_loss.rs rename to crates/gpu/tests/it/device_loss.rs index 294961ee8d..3875bd4a89 100644 --- a/crates/gpu/tests/device_loss.rs +++ b/crates/gpu/tests/it/device_loss.rs @@ -1,6 +1,7 @@ //! The compositor on a lost device (#243): renders return `Unsupported` (so callers use the CPU -//! compositor) instead of panicking with "Buffer 'pc_compose_uniforms' is invalid". Skips when no -//! GPU adapter exists. +//! compositor) instead of panicking with "Buffer 'pc_compose_uniforms' is invalid". Also covers a +//! caught validation error on a watched device, which must fault the health flag so callers stop +//! issuing GPU work. Skips when no GPU adapter exists. use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_doc::{Document, Layer, LayerContent}; @@ -8,8 +9,7 @@ use photocraft_geom::{Rect, Size}; use photocraft_gpu::{Compositor, DeviceHealth, Fault, render_to_vec}; use photocraft_raster::Surface; -/// Concurrent wgpu instances in one process segfault on some drivers (see `parity.rs`). -static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +use crate::GPU_LOCK; fn device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { let instance = wgpu::Instance::default(); @@ -185,6 +185,34 @@ fn a_captured_render_error_preserves_the_device_for_a_later_render() { } } +#[test] +fn a_watched_validation_error_faults_health_instead_of_panicking() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = device() else { return }; + let Ok(mut comp) = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)) else { return }; + let health = DeviceHealth::watch(&device); + comp.set_health(health.clone()); + let d = doc(); + assert!(health.is_ok(), "a fresh watched device starts healthy"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + comp.render(&device, &queue, &d, d.bounds(), |_, _| { + // A buffer past the device's valid limit is rejected synchronously, so the error + // reaches the compositor's error scope and never the uncaptured-error callback a + // watched device would otherwise record it with. + let _ = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("synthetic_invalid_buffer"), + size: device.limits().max_buffer_size + 1, + usage: wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + }) + })); + assert!(result.is_ok(), "a caught validation error must return Err instead of reaching wgpu's panic handler"); + assert!(result.unwrap().is_err(), "the invalid buffer must fail this render"); + assert!(!health.is_ok(), "the caught error must fault the device's health so callers stop issuing GPU work"); + assert!(health.fault().is_some(), "the fault must be reportable"); +} + fn assert_recovery_after_invalid_encoder(encode_only: bool) { let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((adapter, device, queue)) = device() else { return }; diff --git a/crates/gpu/tests/it/main.rs b/crates/gpu/tests/it/main.rs new file mode 100644 index 0000000000..027db9d995 --- /dev/null +++ b/crates/gpu/tests/it/main.rs @@ -0,0 +1,13 @@ +//! The crate's integration tests, linked into one test binary. + +/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so every test that +/// creates a device takes this lock and holds it until the device is dropped. +static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +mod camera_raw; +mod device_loss; +mod parity; +mod pipeline_errors; +mod timing; +mod uniform_limits; +mod vector_mask; diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/it/parity.rs similarity index 91% rename from crates/gpu/tests/parity.rs rename to crates/gpu/tests/it/parity.rs index cacd79c0ff..14a3e62243 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -12,9 +12,7 @@ use photocraft_gpu::{Compositor, render_to_vec}; /// lands on that side at the Hue/Saturation pixel in `adjustment_layers`). const TOL: f32 = 1.0 / 255.0 + 4.0 * f32::EPSILON; -/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so the GPU tests -/// take this lock and hold it until their device is dropped. -static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +use crate::GPU_LOCK; struct Gpu { device: wgpu::Device, @@ -59,6 +57,14 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { black: Vec::new(), pencil: false, }, + // #7157: an edited curve continues past 0..1 along its end segments (Photoshop's `[[0,0],[255,128]]`). + Adjustment::Curves { + master: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 128.0 / 255.0 }], + per_channel: std::array::from_fn(|_| identity.clone()), + space: Default::default(), + black: Vec::new(), + pencil: false, + }, // 32-bit Photoshop rules: unclipped, legacy pivot 127, the mixer's 1/1024 steps and halved constant. Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }, Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true }, @@ -67,6 +73,7 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { Adjustment::PhotoFilter { color: [0.97, 0.76, 0.0], density: 0.4, preserve_luminosity: true }, Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false }, Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) }, + Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None }, ] { let mut d = Document::new("HDR adjustments", Size::new(3, 1), ColorMode::Rgb, SampleType::F32); let mut layer = Layer::raster("pixels", PixelFormat::RGBA32F); @@ -711,6 +718,29 @@ fn lookup(n: usize, tetrahedral: bool, dither: bool) -> Adjustment { Adjustment::ColorLookup { name: "test".into(), lut: Some(std::sync::Arc::new(t)), size: n as u32, tetrahedral, dither } } +/// 32-bit Hue/Saturation on colours at and above HSL lightness 1 and with a negative channel, +/// where the HSL saturation divides by zero: the GPU scales the chroma as the CPU does +/// (compose::adjust::hue_saturation_in), finite and unclipped. +#[test] +fn hdr_hue_saturation_bright_colours() { + let Some(mut g) = gpu() else { return }; + let mut d = Document::new("t", Size::new(4, 1), ColorMode::Rgb, SampleType::F32); + let mut bg = Layer::raster("bg", d.pixel_format()); + let px = [2.0, 0.0, 0.0, 1.0, 0.25, -0.25, 0.0, 1.0, 1.1, 0.9, 1.0, 1.0, 0.55, 0.45, 0.5, 1.0]; + bg.surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 4, 1), &px); + d.layers.push(bg); + for (hue, saturation) in [(40.0, 0.0), (0.0, 25.0), (0.0, -25.0), (40.0, 75.0)] { + let hs = Adjustment::HueSaturation { hue, saturation, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs))); + check(&mut g, &d, &format!("hue {hue} saturation {saturation} on bright colours")); + d.layers.pop(); + } + // A PSD can store a Colorize saturation beyond 100: clamped before the 32-bit scaling on both. + let hs = Adjustment::HueSaturation { hue: 200.0, saturation: 150.0, lightness: 50.0, colorize: true, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs))); + check(&mut g, &d, "colorize saturation 150"); +} + #[test] fn adjustment_layers() { let Some(mut g) = gpu() else { return }; @@ -730,6 +760,14 @@ fn adjustment_layers() { let pf = Adjustment::PhotoFilter { color: [0.93, 0.54, 0.0], density: 0.6, preserve_luminosity: true }; d.layers.push(Layer::new("pf", LayerContent::Adjustment(pf))); check(&mut g, &d, &format!("photo filter {depth:?}")); + // Colorize: the saturation scaled by 1 - |lightness| at 32 bits (compose::adjust::colorize_saturation). + let hs = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs", LayerContent::Adjustment(hs))); + check(&mut g, &d, &format!("colorize {depth:?}")); + // Hue/Saturation: at 32 bits the raised saturation isn't clipped. + let hs = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() }; + d.layers.push(Layer::new("hs2", LayerContent::Adjustment(hs))); + check(&mut g, &d, &format!("hue/saturation {depth:?}")); // Threshold: whole 16-bit samples against the level in 16 bits, or the float luminosity. d.layers.push(Layer::new("th", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 }))); check(&mut g, &d, &format!("threshold {depth:?}")); @@ -794,6 +832,19 @@ fn groups_masks_and_clipping() { d.layers.extend([base, c1, c2]); check(&mut g, &d, &format!("clipping hidden={hidden}")); } + // #3052/#4921: a clipped Dissolve layer retains opacity/fill binary coverage + // across the partially covered clipping base; the GPU must match the CPU. + for (opacity, fill) in [(0.4, 1.0), (0.8, 0.5)] { + let mut d = base_doc(56, 44); + let base = noise_layer("base", PixelFormat::RGBA8, Rect::new(5, 6, 48, 40), 45, 0.0); + let mut clip = noise_layer("dissolve clip", PixelFormat::RGBA8, d.bounds(), 46, 0.0); + clip.clipped = true; + clip.blend = BlendMode::Dissolve; + clip.opacity = opacity; + clip.fill_opacity = fill; + d.layers.extend([base, clip]); + check(&mut g, &d, "clipped Dissolve opacity/fill CPU/GPU parity"); + } // Clipped group base (isolated) with a clipped layer. let mut d = base_doc(56, 44); let mut gb = Layer::group("gb", vec![child(18, BlendMode::Normal)]); @@ -913,7 +964,7 @@ fn hard_stop_effects_and_gradient_maps_request_cpu() { } let paint = FxPaint::Gradient(gradient.clone()); let effects = [ - Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient, dither: false }, + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient, dither: false, interpolation: None }, Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 1.0, @@ -1534,6 +1585,14 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { let mut bevel_contour = bevel(BevelStyle::InnerBevel, true, 8.0, 3.0); bevel_contour.gloss_contour = contour(); bevel_contour.use_global_light = false; + // #7360: a gloss contour that starts at 100 % (Rolling Slope - Descending), as on the type in + // the report; its flat face and slopes take the contour-mapped shading on both renderers. + let mut bevel_gloss_high = bevel(BevelStyle::InnerBevel, true, 8.0, 2.0); + bevel_gloss_high.gloss_contour = Contour::Custom { + name: "descending".into(), + points: vec![Cp { input: 0.0, output: 1.0 }, Cp { input: 0.4, output: 0.4 }, Cp { input: 1.0, output: 0.0 }], + corners: vec![], + }; vec![ ("drop shadow", vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.75, 120.0, 6.0, 8.0, 0.0))]), ("drop shadow spread contour", vec![Effect::DropShadow(ds_contour)]), @@ -1559,6 +1618,29 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { vec![Effect::InnerGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.8)), GlowTechnique::Precise, 6.0, 0.3, GlowSource::Edge))], ), ("inner glow precise center", vec![Effect::InnerGlow(glow(pat_paint.clone(), GlowTechnique::Precise, 5.0, 0.0, GlowSource::Center))]), + // Dissolve effects (#6995): the same speckle as the CPU, pixel for pixel. + ( + "outer glow dissolve", + vec![Effect::OuterGlow(Glow { + common: FxCommon::new(BlendMode::Dissolve, 0.8), + ..glow(FxPaint::Color(Color::rgb(1.0, 0.9, 0.2)), GlowTechnique::Softer, 10.0, 0.3, GlowSource::Edge) + })], + ), + ( + "inner glow dissolve gradient", + vec![Effect::InnerGlow(Glow { + common: FxCommon::new(BlendMode::Dissolve, 1.0), + ..glow(FxPaint::Gradient(gradient()), GlowTechnique::Softer, 7.0, 0.1, GlowSource::Edge) + })], + ), + ("drop shadow dissolve", vec![Effect::DropShadow(shadow(BlendMode::Dissolve, 0.75, 120.0, 6.0, 8.0, 0.0))]), + ( + "stroke outside dissolve", + vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Dissolve, 0.6), + ..stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.2, 0.9))) + })], + ), ("bevel inner", vec![Effect::BevelEmboss(bevel(BevelStyle::InnerBevel, true, 7.0, 2.0))]), ("bevel outer", vec![Effect::BevelEmboss(bevel(BevelStyle::OuterBevel, true, 6.0, 0.0))]), ("bevel emboss down", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, false, 8.0, 1.0))]), @@ -1600,6 +1682,7 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { })], ), ("bevel contour own light", vec![Effect::BevelEmboss(bevel_contour)]), + ("bevel gloss starting high", vec![Effect::BevelEmboss(bevel_gloss_high)]), ("satin", vec![Effect::Satin(satin)]), ("satin inverted contour", vec![Effect::Satin(satin_inv)]), ("stroke outside colour", vec![Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.95, 0.85, 0.1))))]), @@ -1612,6 +1695,7 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { common: FxCommon::new(BlendMode::Normal, 0.8), gradient: Gradient { style: GradientStyle::Radial, ..gradient() }, dither: false, + interpolation: None, }], ), ( @@ -1683,7 +1767,7 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { invert: false, }), Effect::ColorOverlay { common: FxCommon::new(BlendMode::SoftLight, 0.5), color: Color::rgb(0.9, 0.3, 0.2) }, - Effect::GradientOverlay { common: FxCommon::new(BlendMode::Overlay, 0.4), gradient: gradient(), dither: false }, + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Overlay, 0.4), gradient: gradient(), dither: false, interpolation: None }, Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))), ], ), @@ -1803,6 +1887,24 @@ fn layer_effects_opacity_fill_blend_and_off_canvas() { d.layers.push(l); fx_check(&mut g, &d, &format!("outside stroke at fill {fill}")); } + // An Outside stroke has no share beneath fully opaque pixels (#5861): non-Normal layers with + // an Outside stroke on a soft blob and a hard square. (Blend Interior Effects as Group, where + // that share shows through the layer's mode, is composited on the CPU.) + for blend in [BlendMode::Multiply, BlendMode::Screen] { + let mut d = fx_doc(80, 80, SampleType::U8); + let mut l = blob("ring", d.pixel_format(), 40.0, 40.0, 20.0, [0.9, 0.1, 0.1]); + l.effects.items = vec![Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.2, 0.9))))]; + l.blend = blend; + d.layers.push(l); + fx_check(&mut g, &d, &format!("outside stroke under a {blend:?} blob")); + let mut d = fx_doc(40, 40, SampleType::U8); + let mut l = Layer::raster("square", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]); + l.effects.items = vec![Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 1.0))))]; + l.blend = blend; + d.layers.push(l); + fx_check(&mut g, &d, &format!("outside stroke under a {blend:?} square")); + } // Shapes partly off the canvas (their effects reach back in) and a masked effect layer. let mut d = fx_doc(90, 70, SampleType::U8); let mut l = blob("edge", d.pixel_format(), 4.0, 66.0, 18.0, [0.9, 0.9, 0.2]); @@ -2813,6 +2915,59 @@ fn layer_pages_are_admitted_under_a_zero_budget_and_stay_on_the_gpu() { check(&mut g, &d, "after zero-budget renders"); } +/// A layer page texture the device has no memory for (#6563): the compositor drops the pages +/// this cell doesn't use and tries again, then submits, waits and runs the cell once more, and +/// only then ends this refresh on the CPU. The device stays healthy and the next refresh is back +/// on the GPU. The test hook makes the next `n` page textures fail as out of memory. +#[test] +fn a_layer_page_out_of_memory_retries_then_ends_only_that_refresh_on_the_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(600, 500); + d.layers.push(noise_layer("mid", PixelFormat::RGBA8, Rect::new(-20, 10, 590, 480), 71, 0.0)); + let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(30, -15, 620, 510), 72, 0.2); + top.blend = BlendMode::Multiply; + d.layers.push(top); + g.paged.set_composite_cache_budget(0); + let cpu = photocraft_compose::flatten(&d); + let encode = |g: &mut Gpu, d: &Document| { + let mut encoder = g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + g.paged.encode(&g.device, &g.queue, &mut encoder, d, d.bounds(), &mut |_, _| {}) + }; + let render = |g: &mut Gpu, d: &Document| photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, d, d.bounds()); + let matches = |out: &[[f32; 4]], what: &str| { + let worst = worst_diff(&cpu.px, out); + assert!(worst.0 <= TOL, "{what}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1); + }; + // Every case starts with no pages, so the first page texture is the one that fails. + let fresh = |g: &mut Gpu, d: &Document, n: u32| { + g.paged.forget_doc(d.id); + g.paged.fail_layer_allocs(n); + }; + + // One failure: the second try right away succeeds (`encode` can't submit, so this is the + // only retry it has). + fresh(&mut g, &d, 1); + encode(&mut g, &d).unwrap_or_else(|e| panic!("one failed page texture is retried at once: {}", e.0)); + // Two: both tries fail, and `encode` gives the refresh to the CPU. + fresh(&mut g, &d, 2); + let e = encode(&mut g, &d).expect_err("encode can't submit to free memory"); + assert_eq!(e.0, photocraft_gpu::OVER_BUDGET); + assert!(g.paged.fault().is_none(), "out of memory for a page doesn't lose the device"); + // `render` submits, waits and runs the cell once more, which then succeeds. + fresh(&mut g, &d, 2); + let (out, s) = render(&mut g, &d).unwrap_or_else(|e| panic!("render retries after submitting: {}", e.0)); + assert!(s.flushes > 0, "{s:?}"); + matches(&out, "after the submit-and-retry"); + // Four: the retry after the submit fails as well, so this refresh goes to the CPU... + fresh(&mut g, &d, 4); + let e = render(&mut g, &d).expect_err("every try failed"); + assert_eq!(e.0, photocraft_gpu::OVER_BUDGET); + assert!(g.paged.fault().is_none(), "out of memory for a page doesn't lose the device"); + // ...and the next one is back on the GPU. + let (out, _) = render(&mut g, &d).unwrap_or_else(|e| panic!("the refresh after an out-of-memory one is on the GPU: {}", e.0)); + matches(&out, "the refresh after the CPU one"); +} + #[test] fn the_focused_view_stays_resident_after_a_full_refresh() { let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner()); @@ -2842,6 +2997,45 @@ fn the_focused_view_stays_resident_after_a_full_refresh() { } } +#[test] +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(); + 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 + // pages' own share misses by about 6 %, which the effect-map share covers (#5973, P60). + let budget = need / 4 * 5; + comp.set_memory_budget(budget); + let own = comp.resident_budget(); + assert!(own < need && !comp.fits_budget(&d, d.bounds())); + assert_eq!(comp.fit_layers(need, true), need, "lends exactly the shortfall"); + assert_eq!(comp.memory_budget(), budget, "the total stays"); + comp.set_memory_budget(budget); + assert_eq!(comp.resident_budget(), need, "the loan holds across the budget set before every refresh"); + assert_eq!(comp.fit_layers(need / 2, false), need, "a partial refresh doesn't shrink it"); + photocraft_gpu::render_to_vec(&mut comp, &g.device, &g.queue, &d, d.bounds()).unwrap(); + let (out, s) = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, d.bounds()).unwrap(); + // Every page stays resident; effect maps may be evicted and rebuilt, that's the loan. + assert_eq!(s.tiles_uploaded, 0, "{s:?}"); + assert!(worst_diff(&photocraft_compose::render(&d, d.bounds()).px, &out).0 <= TOL); + // A need the effect-map share can't cover lends nothing (it would only evict maps), and a + // smaller need gives the loan back. + assert_eq!(comp.fit_layers(budget, true), own); + comp.fit_layers(need, true); + assert_eq!(comp.fit_layers(own, true), own); + assert_eq!(comp.memory_budget(), budget); + // An exact resident budget returns the loan and lends nothing until the next memory budget. + comp.fit_layers(need, true); + comp.set_resident_budget(own); + assert_eq!(comp.memory_budget(), budget, "the loan went back to the effect maps"); + assert_eq!(comp.fit_layers(need, true), own, "an exact budget stays exact"); + comp.set_memory_budget(budget); + assert_eq!(comp.resident_budget(), need, "the next memory budget lends again"); +} + // A pass-through group holding an adjustment renders isolated when layers clip to it, on the GPU // as on the CPU (psd-tools passthrough_clipping_mask_adjustment). #[test] @@ -2893,3 +3087,60 @@ fn soft_shapes_match_the_cpu() { } } } + +/// An adjustment has no standalone source alpha: its opacity and mask gate binary coverage. +#[test] +fn dissolve_adjustment_matches_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + adjustment.blend = BlendMode::Dissolve; + adjustment.opacity = 0.5; + adjustment.mask = Some(mask(Rect::new(0, 0, 32, 48), 31, 0.0)); + d.layers.push(adjustment); + check(&mut g, &d, "dissolve adjustment + mask"); +} + +/// Clipped pixels still use document-positioned noise, without changing the base alpha. +#[test] +fn dissolve_clipped_layer_matches_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let mut clipped = Layer::new("clipped", LayerContent::Fill(Fill::Solid(Color::BLACK))); + clipped.clipped = true; + clipped.blend = BlendMode::Dissolve; + clipped.opacity = 0.5; + d.layers.push(clipped); + check(&mut g, &d, "dissolve clipped"); +} + +/// Layer opacity gates the completed effects stack, instead of fading its pixels. +#[test] +fn dissolve_effects_stack_matches_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let mut styled = Layer::new("styled", LayerContent::Fill(Fill::Solid(Color::BLACK))); + styled.blend = BlendMode::Dissolve; + styled.opacity = 0.5; + styled.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK }); + d.layers.push(styled); + check(&mut g, &d, "dissolve effects"); +} + +#[test] +fn dissolve_clipped_adjustment_matches_cpu() { + let Some(mut g) = gpu() else { return }; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut d = Document::new("clipped adjustment", Size::new(272, 24), ColorMode::Rgb, fmt.sample); + let base = noise_layer("base", fmt, Rect::new(-3, -2, 268, 22), 47, 0.0); + let mut adjustment = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + adjustment.clipped = true; + adjustment.blend = BlendMode::Dissolve; + adjustment.opacity = 0.6; + adjustment.fill_opacity = 0.5; + adjustment.mask = Some(mask(Rect::new(0, 0, 260, 24), 48, 0.0)); + d.layers.extend([base, adjustment]); + check(&mut g, &d, &format!("clipped Dissolve adjustment {fmt:?}")); + diff_rect(&mut g, &d, Rect::new(250, 1, 270, 20), "clipped adjustment offset region").unwrap_or_else(|e| panic!("{e}")); + } +} diff --git a/crates/gpu/tests/it/pipeline_errors.rs b/crates/gpu/tests/it/pipeline_errors.rs new file mode 100644 index 0000000000..28d7a56791 --- /dev/null +++ b/crates/gpu/tests/it/pipeline_errors.rs @@ -0,0 +1,50 @@ +//! #6419: the compositor's pipelines are built on several threads. A pipeline the device can't +//! build is still an `Err` from `try_new_with_format`, not a panic from wgpu's default error +//! handler (error scopes are per thread). Skips when no GPU adapter exists. + +use std::sync::Arc; + +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::Document; +use photocraft_geom::Size; +use photocraft_gpu::{Compositor, render_to_vec}; + +use crate::GPU_LOCK; + +fn device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { + let instance = wgpu::Instance::default(); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { + eprintln!("skipping: no adapter"); + return None; + }; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; + Some((adapter, device, queue)) +} + +#[test] +fn a_pipeline_built_on_another_thread_fails_as_an_error() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, _queue)) = device() else { return }; + // Never a render target, so every accumulation pipeline fails validation. + let Err(e) = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgb9e5Ufloat) else { + panic!("a compositor accumulating into Rgb9e5Ufloat"); + }; + assert!(e.0.contains("couldn't build the compositor's pipelines"), "{e:?}"); + // The device is still usable for a compositor it can build. + assert!(Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).is_ok()); +} + +/// `new_with_format` on a device whose errors go to the caller's handler: a pipeline that failed +/// to build stays in the compositor as an invalid handle, so a draw reports GPU errors instead of +/// panicking on a missing pipeline. +#[test] +fn a_compositor_with_failed_pipelines_draws_without_panicking() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((_adapter, device, queue)) = device() else { return }; + // As the app does (`DeviceHealth::watch`): GPU errors go to a handler, not wgpu's default panic. + device.on_uncaptured_error(Arc::new(|_: wgpu::Error| {})); + let mut comp = Compositor::new_with_format(&device, wgpu::TextureFormat::Rgb9e5Ufloat); + let doc = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::rgba(0.5, 0.5, 0.5, 1.0)); + // Drawing into an unrenderable format can't succeed, but must not panic. + let _ = render_to_vec(&mut comp, &device, &queue, &doc, doc.bounds()); +} diff --git a/crates/gpu/tests/timing.rs b/crates/gpu/tests/it/timing.rs similarity index 88% rename from crates/gpu/tests/timing.rs rename to crates/gpu/tests/it/timing.rs index 24c2057253..76348695c5 100644 --- a/crates/gpu/tests/timing.rs +++ b/crates/gpu/tests/it/timing.rs @@ -111,6 +111,7 @@ fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, do #[test] fn gpu_time_tracks_execution_not_submission() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((device, queue, mut comp)) = gpu() else { return }; let size = Size::new(3000, 2000); let doc = document(size); @@ -138,9 +139,11 @@ fn gpu_time_tracks_execution_not_submission() { assert_eq!(t.px, area(full), "the measurement carries the pixels of the refresh it measured"); assert!(t.spans >= 1, "at least one command buffer was timed"); - // The premise: on this adapter the refresh really is dominated by GPU execution. A machine - // fast enough that it isn't cannot tell the two timings apart, so say so instead of failing. - if wall_ms < 3.0 * submit_ms { + // The premise: on this adapter the refresh really is dominated by GPU execution. `wall_ms` + // includes `submit_ms`, so the GPU execution window is `wall_ms - submit_ms`; a machine fast + // enough that this window isn't well above `3.0 * submit_ms` cannot tell the two timings + // apart, so say so instead of failing. + if wall_ms - submit_ms < 3.0 * submit_ms { eprintln!("SKIP the comparison: this GPU finishes a 6 MP composite in {wall_ms:.1} ms against {submit_ms:.1} ms of encoding"); return; } @@ -164,6 +167,7 @@ fn gpu_time_tracks_execution_not_submission() { #[test] fn an_unmeasured_compositor_reports_nothing() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((device, queue, _)) = gpu() else { return }; let mut comp = Compositor::new(&device); assert!(!comp.measures_gpu_time(), "timing is off until it is asked for"); @@ -175,6 +179,7 @@ fn an_unmeasured_compositor_reports_nothing() { #[test] fn a_refresh_that_submits_several_times_is_one_measurement() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((device, queue, mut comp)) = gpu() else { return }; // A budget far below the document's layer pages makes the refresh evict as it goes, which is // what makes it submit in batches: the measurement must span all of them, not just the last. @@ -208,6 +213,7 @@ fn a_refresh_that_submits_several_times_is_one_measurement() { #[test] fn refreshes_faster_than_the_readback_stay_valid() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((device, queue, mut comp)) = gpu() else { return }; // Refreshes arriving before the last measurement has been read back must simply go // unmeasured. Mapping the readback buffer again while its first map is still pending is a @@ -232,6 +238,7 @@ fn refreshes_faster_than_the_readback_stay_valid() { /// wrote no timestamps itself — and the compositor would silently never measure again. #[test] fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some((device, queue, _)) = gpu() else { return }; let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries"); @@ -280,3 +287,25 @@ fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() { let after = after.expect("the timer still measures after a refresh that recorded nothing"); assert_eq!(after.px, 4096, "the measurement belongs to the refresh that produced it"); } + +/// A refresh whose layer pages ran out of GPU memory even after the submit-and-retry ends on the +/// CPU (#6563), but measuring goes on: the retry waited for the GPU, so none of the dropped +/// measurement's timestamps were still in flight. +#[test] +fn an_out_of_memory_refresh_does_not_stop_gpu_timing() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((device, queue, mut comp)) = gpu() else { return }; + let doc = document(Size::new(640, 480)); + let full = doc.bounds(); + // The first page texture fails twice, and twice more in the retry after the submit. + comp.fail_layer_allocs(4); + assert!(comp.render(&device, &queue, &doc, full, |_, _| {}).is_err(), "every try failed"); + let mut timing = None; + for _ in 0..4 { + timing = measure(&device, &queue, &mut comp, &doc, full).2; + if timing.is_some() { + break; + } + } + assert!(timing.is_some(), "GPU timing stopped for good after one out-of-memory refresh"); +} diff --git a/crates/gpu/tests/uniform_limits.rs b/crates/gpu/tests/it/uniform_limits.rs similarity index 99% rename from crates/gpu/tests/uniform_limits.rs rename to crates/gpu/tests/it/uniform_limits.rs index 994dbd7508..90dfa57aed 100644 --- a/crates/gpu/tests/uniform_limits.rs +++ b/crates/gpu/tests/it/uniform_limits.rs @@ -5,7 +5,7 @@ use photocraft_doc::{Document, Fill, Layer, LayerContent}; use photocraft_geom::Size; use photocraft_gpu::{Compositor, DeviceHealth, plan, render_to_vec}; -static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +use crate::GPU_LOCK; fn fills(count: usize) -> Document { fills_size(count, Size::new(1, 1)) diff --git a/crates/gpu/tests/vector_mask.rs b/crates/gpu/tests/it/vector_mask.rs similarity index 98% rename from crates/gpu/tests/vector_mask.rs rename to crates/gpu/tests/it/vector_mask.rs index 43d3d081e3..d0d7e7a012 100644 --- a/crates/gpu/tests/vector_mask.rs +++ b/crates/gpu/tests/it/vector_mask.rs @@ -47,6 +47,7 @@ fn painted(name: &str, fmt: PixelFormat, rgba: [f32; 4]) -> Layer { #[test] fn vector_masks_match_the_cpu() { + let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let Some(mut g) = gpu() else { return }; for depth in [SampleType::U8, SampleType::U16] { for (inverted, density) in [(false, 1.0), (true, 1.0), (false, 0.6)] { diff --git a/crates/heif/README.md b/crates/heif/README.md index 8915129443..9d859e8401 100644 --- a/crates/heif/README.md +++ b/crates/heif/README.md @@ -30,5 +30,5 @@ inside it (fuzzing found two in 0.1.1) becomes `Error::Malformed`. The crate depends on no other PhotoCraft crate and knows nothing of `photocraft-codecs`' types; `codecs` → `heif` is the one allowed dependency between standalone crates (`cargo xtask layers`). -Tests on real files live in `photocraft-codecs` (`tests/heif.rs`, features `corpus,heif`, files +Tests on real files live in `photocraft-codecs` (`tests/it/heif.rs`, features `corpus,heif`, files fetched by `cargo xtask corpus --heif`). diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 74ea6a1b5a..4b12dd8763 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -39,7 +39,7 @@ blake3 = { version = "1.8", default-features = false, features = ["pure"] } # pdn.rs inflates Paint.NET's gzip pixel chunks. flate2 = { workspace = true } # ora.rs reads OpenRaster's stack.xml (already in the tree through usvg). -roxmltree = "0.20" +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 @@ -56,4 +56,9 @@ photocraft-affinity = { workspace = true, features = ["synth"] } flate2 = { workspace = true } photocraft-psd = { workspace = true, features = ["testgen"] } photocraft-raw = { workspace = true, features = ["testgen"] } +photocraft-testkit = { workspace = true } proptest = { workspace = true } +# Lossless JPEG XL encoder for the JPEG XL DNG preview test (tests only), as in photocraft-raw. +zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] } +zune-core = { workspace = true } +tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] } diff --git a/crates/io/examples/big_psb_check.rs b/crates/io/examples/big_psb_check.rs index 3d0f407583..3b1c21034b 100644 --- a/crates/io/examples/big_psb_check.rs +++ b/crates/io/examples/big_psb_check.rs @@ -2,7 +2,7 @@ //! //! `cargo run --release -p photocraft-io --example big_psb_check -- ...` //! -//! The opt-in tests in `tests/large_files.rs` generate and check the same files. +//! The opt-in tests in `tests/it/large_files.rs` generate and check the same files. use photocraft_psd::fixtures::sample; diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index a7f295d463..fddef567a9 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -437,7 +437,7 @@ fn main() { } } -/// The corpus oracle table (as `tests/corpus.rs`), files in parallel, without a test build. +/// The corpus oracle table (as `tests/it/corpus.rs`), files in parallel, without a test build. fn corpus_summary(root: &std::path::Path) { fn collect(dir: &std::path::Path, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index 549ac40970..a41ba3ac01 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -57,7 +57,7 @@ pub fn map_file(f: &AbrFile, group: &str) -> AbrImport { /// [`map_file`] that checks `ctl` before each brush. pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrupt) -> Result { let mut out = AbrImport { version: f.version, warnings: f.warnings.clone(), ..Default::default() }; - let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new() }; + let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new(), tips: Default::default() }; let total = f.legacy.len().max(f.samples.len()).max(f.presets.len()).max(1) as f32; let step = |i: usize| -> Result<(), photocraft_raster::Cancelled> { ctl.check()?; @@ -255,18 +255,26 @@ struct Mapper<'a> { file: &'a AbrFile, warnings: BTreeSet, unknown: BTreeSet, + /// Tips already converted, by sample: presets can name one sample thousands of times, and a + /// tile clones by sharing its data. + tips: std::collections::HashMap<*const AbrSample, (photocraft_paint::tile::GrayTile, f32)>, } impl Mapper<'_> { /// A sampled tip (downsampled to [`MAX_TIP_EDGE`]) and its size (larger side). fn tip(&mut self, s: &AbrSample) -> (TipShape, f32) { + if let Some((tile, size)) = self.tips.get(&std::ptr::from_ref(s)) { + return (TipShape::Sampled(tile.clone()), *size); + } let v = s.to_unit(); let (w, h, v) = fit(s.width, s.height, v, MAX_TIP_EDGE); if (w, h) != (s.width, s.height) { self.warnings.insert(format!("tips larger than {MAX_TIP_EDGE} px were downsampled")); } let data = v.iter().map(|x| (x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect(); - (TipShape::Sampled(GrayTile { width: w, height: h, data }), s.width.max(s.height) as f32) + let (tile, size) = (GrayTile { width: w, height: h, data }, s.width.max(s.height) as f32); + self.tips.insert(std::ptr::from_ref(s), (tile.clone(), size)); + (TipShape::Sampled(tile), size) } fn dynamic(&mut self, d: Option<&Descriptor>) -> Dynamic { @@ -473,7 +481,8 @@ impl Mapper<'_> { tilt_y: (num(d, "brushPoseTiltY").unwrap_or(0.0) * 0.9).clamp(-90.0, 90.0) as f32, rotation: num(d, "brushPoseAngle").unwrap_or(0.0).rem_euclid(360.0) as f32, pressure: pct(d, "brushPosePressure").unwrap_or(1.0).clamp(0.0, 1.0), - override_tilt: boolean(d, "overridePoseTiltX").unwrap_or(false) || boolean(d, "overridePoseTiltY").unwrap_or(false), + override_tilt_x: boolean(d, "overridePoseTiltX").unwrap_or(false), + override_tilt_y: boolean(d, "overridePoseTiltY").unwrap_or(false), override_rotation: boolean(d, "overridePoseAngle").unwrap_or(false), override_pressure: boolean(d, "overridePosePressure").unwrap_or(false), }; diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 501e331af9..26f68d7aa0 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -128,6 +128,43 @@ fn grit_pattern() -> PsdPattern { } } +#[test] +fn pose_axes_abr_import_preserves_independent_stylus_tilt() { + let input = photocraft_paint::StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, wheel: 0.24, time: 73.0, ..Default::default() }; + for subversion in [1, 2] { + for (x, y) in [(false, false), (true, false), (false, true), (true, true)] { + let preset = Descriptor::new("brushPreset") + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 47.0)))) + .with("useBrushPose", Value::Boolean(true)) + .with("brushPoseTiltX", prc(50.0)) + .with("brushPoseTiltY", prc(-20.0)) + .with("overridePoseTiltX", Value::Boolean(x)) + .with("overridePoseTiltY", Value::Boolean(y)); + let bytes = write_v6(subversion, &[], &[], &[preset], false).unwrap(); + let imported = read_abr(&bytes, "Pose axes").unwrap(); + assert_eq!(imported.presets.len(), 1); + let b = &imported.presets[0].brush; + assert!(b.pose.enabled); + assert_eq!((b.pose.tilt_x, b.pose.tilt_y), (45.0, -18.0), "ABR percentages retain the W3C degree conversion"); + let mut expected = input; + if x { + expected.tilt_x = 45.0; + } + if y { + expected.tilt_y = -18.0; + } + assert_eq!( + photocraft_paint::dynamics::apply_pose(b, input), + expected, + "v6.{subversion}, X={x}, Y={y}: one axis must not override the other's tablet data" + ); + let stored = serde_json::to_vec(&imported.presets[0]).unwrap(); + let restored: photocraft_paint::BrushPreset = serde_json::from_slice(&stored).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&restored.brush, input), expected, "preset persistence retains the independent flags"); + } + } +} + #[test] fn v6_settings_map_onto_brush_sections() { for sub in [1, 2] { @@ -166,7 +203,7 @@ fn v6_settings_map_onto_brush_sections() { assert!(b.transfer.enabled); assert_eq!((b.transfer.opacity.control, b.transfer.opacity.minimum), (Control::PenPressure, 0.1)); assert_eq!((b.transfer.flow.control, b.transfer.flow.jitter), (Control::Fade, 0.15)); - assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt); + assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt_x && !b.pose.override_tilt_y); assert_eq!((b.pose.tilt_x, b.pose.rotation), (45.0, 90.0)); assert!(b.wet_edges && b.noise && b.build_up); assert_eq!((b.opacity, b.flow, b.smoothing.amount, b.pressure_size), (0.8, 0.6, 0.4, true)); diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 9cd7479b81..3a9d18f94e 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -29,7 +29,7 @@ fn bef32(d: &[u8], at: usize) -> Option { } fn unsupported(key: &[u8; 4], data: &[u8]) -> Adjustment { - Adjustment::Unsupported { psd_key: String::from_utf8_lossy(key).into_owned(), raw: data.to_vec() } + Adjustment::Unsupported { psd_key: String::from_utf8_lossy(key).into_owned(), raw: data.into() } } fn levels_rec(d: &[u8], at: usize) -> Option { @@ -139,6 +139,19 @@ pub fn label(key: &[u8; 4]) -> &'static str { } } +/// Keys of a Brightness/Contrast `CgEd` descriptor: the ones [`write`] emits plus Photoshop's +/// preset bookkeeping. +const BRIT_CGED_KEYS: [&str; 9] = ["Vrsn", "Brgh", "Cntr", "means", "Lab ", "useLegacy", "Auto", "presetKind", "presetFileName"]; + +/// Whether a `brit` layer's `CgEd` descriptor holds settings Brightness/Contrast has no place +/// for. Photoshop 2026's Light adjustment (Exposure, Contrast, Highlights, Shadows, Whites, +/// Blacks) is stored under the `brit` key; read as Brightness/Contrast it kept only Contrast and +/// lost the rest without a word (#6542), so such a layer stays [`Adjustment::Unsupported`]. +pub fn is_extended_brit(cged: Option<&[u8]>) -> bool { + cged.and_then(|c| VersionedDescriptor::parse_prefix(c).ok()) + .is_some_and(|(v, _)| v.descriptor.items.iter().any(|(k, _)| !BRIT_CGED_KEYS.iter().any(|n| k.is(n)))) +} + /// Why [`parse`] keeps a block as [`Adjustment::Unsupported`], when there is more to say than /// "it could not be read" (so far: Color Lookup). pub fn unreadable_reason(key: &[u8; 4], data: &[u8]) -> Option { @@ -204,6 +217,8 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment { b"nvrt" => Some(Adjustment::Invert), b"thrs" => be16(data, 0).map(|v| Adjustment::Threshold { level: f32::from(v) / 255.0 }), b"post" => be16(data, 0).map(|v| Adjustment::Posterize { levels: u32::from(v) }), + // Kept raw (with its `CgEd`, which the PSD writer keeps while the layer is unchanged). + b"brit" if is_extended_brit(cged) => None, b"brit" => { let modern = cged.and_then(|c| VersionedDescriptor::parse_prefix(c).ok()).and_then(|(v, _)| { let d = v.descriptor; @@ -450,6 +465,12 @@ fn read_lookup(d: &[u8]) -> Result { _ => ("x.cube", "CUBE"), }; let lut = photocraft_cms::lutfile::parse(ext, bytes).map_err(|e| format!("its {format} LUT can't be read ({e})"))?; + if !lut.domain_is_default() { + return Err(format!( + "its LUT has a non-default input domain (DOMAIN_MIN {:?}, DOMAIN_MAX {:?}), which is not supported yet", + lut.domain_min, lut.domain_max + )); + } let name = desc_text(&desc, "LUT3DFileName").or_else(|| desc_text(&desc, "NM ")).unwrap_or_default(); Ok(Adjustment::ColorLookup { name, @@ -813,7 +834,7 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { Adjustment::Unsupported { psd_key, raw } => { let k = psd_key.as_bytes(); if k.len() == 4 { - return vec![([k[0], k[1], k[2], k[3]], raw.clone())]; + return vec![([k[0], k[1], k[2], k[3]], raw.to_vec())]; } } } @@ -1032,7 +1053,7 @@ mod tests { let inkl = Adjustment::Levels { master: lc(0, 255), per_channel: [lc(0, 255), lc(5, 250), lc(0, 255)], space: ToneSpace::Cmyk, black: lc(30, 255) }; let b = write(&inkl); assert_eq!(parse(&b[0].0, &b[0].1, None, Channels::Cmyk), inkl); - rt(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![1, 2, 3] }); + rt(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![1, 2, 3].into() }); rt(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (0.5, [1.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: true, dither: false }); rt(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: false, dither: true }); rt(Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -100.0, 100.0]) }); @@ -1112,6 +1133,46 @@ mod tests { assert!(matches!(parse(b"selc", &[0, 1, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); } + fn cube_lookup_block(cube: &[u8]) -> Vec { + let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; + let desc = Descriptor::new("null") + .with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE")) + .with("LUT3DFileName", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("domain.cube"))) + .with("LUT3DFileData", Value::RawData(cube.to_vec())); + let mut block = vec![0, 1]; + block.extend(VersionedDescriptor::new(desc).to_bytes()); + block + } + + fn identity_cube(domain: &str) -> Vec { + format!("{domain}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").into_bytes() + } + + #[test] + fn color_lookup_custom_domains_are_kept_unsupported() { + let domains = ["DOMAIN_MIN 0 0 0\nDOMAIN_MAX 0.5 0.5 0.5\n", "DOMAIN_MIN 0 0.25 0\nDOMAIN_MAX 1 0.75 1\n", "LUT_3D_INPUT_RANGE 0 0.5\n"]; + for domain in domains { + let raw = cube_lookup_block(&identity_cube(domain)); + let Adjustment::Unsupported { psd_key, raw: kept } = parse(b"clrL", &raw, None, Channels::Rgb) else { + panic!("custom LUT domain was applied instead of kept unsupported: {domain}") + }; + assert_eq!(psd_key, "clrL"); + assert_eq!(*kept, *raw); + let reason = unreadable_reason(b"clrL", &raw).expect("custom domain has an explanation"); + assert!(reason.contains("DOMAIN_MIN") && reason.contains("DOMAIN_MAX"), "{reason}"); + assert!(reason.to_ascii_lowercase().contains("not supported"), "{reason}"); + } + } + + #[test] + fn color_lookup_default_domains_stay_supported() { + for domain in ["", "DOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n"] { + let raw = cube_lookup_block(&identity_cube(domain)); + assert!(matches!(parse(b"clrL", &raw, None, Channels::Rgb), Adjustment::ColorLookup { size: 2, .. })); + assert_eq!(unreadable_reason(b"clrL", &raw), None); + } + } + #[test] fn malformed_falls_back_to_unsupported() { assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); @@ -1346,7 +1407,7 @@ mod tests { assert_eq!(*t, photocraft_cms::lutfile::LutFile::identity(2).data); } // Every kind except a malformed Unsupported key has an encoding. - assert!(write(&Adjustment::Unsupported { psd_key: "abc".into(), raw: vec![] }).is_empty()); + assert!(write(&Adjustment::Unsupported { psd_key: "abc".into(), raw: Vec::new().into() }).is_empty()); } #[test] diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index 639fc18fee..f794e1be40 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -28,6 +28,10 @@ pub const EXTENSIONS: &[&str] = &["af", "afphoto", "afdesign", "afpub"]; const MAX_PIXELS: u64 = 64 << 20; /// Canvas side in pixels. const MAX_SIDE: f64 = 300_000.0; +/// Decoded pixel bytes allowed across the whole import: the caps above are per call, so a small +/// container of embedded images could still decode (and keep) a surface each until the process +/// dies. ORA and KRA budget their decoded pixels the same way. +const MAX_PIXEL_BYTES: u64 = 1 << 30; pub fn has_extension(name: &str) -> bool { name.rsplit(['/', '\\']).next().and_then(|n| n.rsplit_once('.')).is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) @@ -43,7 +47,7 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result // Type layers draw from their model, within the import's raster bound: the sizes and // frames come from an untrusted file. let mut document = b.doc; - crate::text_import::prepare(&mut document); + crate::text_import::prepare(&mut document, None); // Save never writes back over the Affinity file. Ok(ImportResult { document, warnings, source_read_only: true, preview_only: false }) } @@ -82,6 +86,20 @@ struct Builder { /// Document pixels (in the canvas) per Affinity document pixel: always 1; spreads are offset. format: PixelFormat, canvas: Rect, + /// Decoded pixel bytes left for embedded images and their resampled surfaces. + pixel_budget: u64, +} + +/// Deduct `cost` bytes from a decoded-pixel budget; `false` (leaving the budget alone) when it +/// cannot cover them. +fn charge(budget: &mut u64, cost: u64) -> bool { + match budget.checked_sub(cost) { + Some(rest) => { + *budget = rest; + true + } + None => false, + } } fn affine(a: af::Affine) -> Affine { @@ -108,7 +126,7 @@ impl Builder { let mut doc = Document::new(name, size, ColorMode::Rgb, SampleType::U8); doc.resolution_dpi = d.dpi as f32; let canvas = Rect::new(0, 0, size.width as i32, size.height as i32); - Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas }) + Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas, pixel_budget: MAX_PIXEL_BYTES }) } fn warn(&mut self, w: &str) { @@ -425,7 +443,17 @@ impl Builder { }) }); let mut sh = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; - sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas)); + // The pixel cache is optional (the shape renders from its path without it), so charge the + // area it would cover to the import's pixel budget and go without it when that is spent: + // geometry across a 300000 px canvas would otherwise be rasterized for every shape. + let reach = sh.stroke.as_ref().map_or(0, |s| (s.width.ceil() as i32).saturating_add(1)); + let area = frame.inflate(reach).intersect(&self.canvas); + let cost = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.format.bytes_per_pixel() as u64); + if charge(&mut self.pixel_budget, cost) { + sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas)); + } else { + self.warn("shapes covering more pixels than the import's memory allows were left without a pixel cache"); + } sh } @@ -494,6 +522,10 @@ impl Builder { match photocraft_codecs::decode_with(bytes, &opts) { Ok(d) => { let (w, h) = d.dimensions(); + if !charge(&mut self.pixel_budget, u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4)) { + self.warn("the document's embedded images need more pixel memory than the import allows; the rest were left out"); + return None; + } Some((w, h, d.to_rgba8())) } Err(_) => { @@ -548,6 +580,10 @@ impl Builder { let exact = a == 1.0 && d == 1.0 && b == 0.0 && c == 0.0 && e.fract() == 0.0 && f.fract() == 0.0; let inv = m.inverse()?; let (rw, rh) = (r.width() as usize, r.height() as usize); + if !charge(&mut self.pixel_budget, (rw as u64).saturating_mul(rh as u64).saturating_mul(channels as u64)) { + self.warn("the document's images need more pixel memory than the import allows; the rest were left out"); + return None; + } let mut buf = vec![0u8; rw * rh * channels]; let at = |x: i64, y: i64, ch: usize| -> f64 { if x < 0 || y < 0 || x >= i64::from(w) || y >= i64::from(h) { @@ -619,3 +655,75 @@ fn color(c: ap::Color) -> Color { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A transparent 64×64 page holding one image node per entry; the identity transform maps + /// each image onto the page's top-left corner. + fn affinity_document(images: Vec<(u32, u32, Pixels)>) -> af::Document { + let nodes = images + .into_iter() + .enumerate() + .map(|(i, (width, height, pixels))| af::Node { + class: photocraft_affinity::synth::tag(b"ImgN"), + name: format!("Image {i}"), + visible: true, + locked: false, + opacity: 1.0, + blend: Blend::Normal, + kind: Kind::Image(af::Image { width, height, pixels, transform: af::Affine::IDENTITY }), + mask: None, + pixel_mask: None, + children: Vec::new(), + }) + .collect(); + af::Document { + dpi: 72.0, + spreads: vec![af::Spread { bounds: af::Rect { x0: 0.0, y0: 0.0, x1: 64.0, y1: 64.0 }, pages: Vec::new(), transparent: true, nodes }], + saved_by: None, + warnings: Vec::new(), + } + } + + #[test] + fn the_pixel_budget_refuses_what_does_not_fit() { + let mut budget = 100u64; + assert!(charge(&mut budget, 60)); + assert!(!charge(&mut budget, 60), "a later image cannot overdraw the import's budget"); + assert_eq!(budget, 40, "a refused charge leaves the budget alone"); + } + + #[test] + fn images_past_the_pixel_budget_are_left_out() { + // Two 64×64 RGBA images cost 16 KiB each when resampled; a 16 KiB budget admits one and + // the second is dropped with a warning instead of being staged anyway. + let d = affinity_document(vec![(64, 64, Pixels::Rgba8(vec![0; 64 * 64 * 4])), (64, 64, Pixels::Rgba8(vec![0; 64 * 64 * 4]))]); + let mut b = Builder::new("budget", &d).unwrap(); + b.pixel_budget = 64 * 64 * 4; + b.document(&d).unwrap(); + assert_eq!(b.doc.layers.len(), 1, "the image past the budget must not import"); + assert!(b.warnings.iter().any(|w| w.contains("pixel memory")), "{:?}", b.warnings); + } + + #[test] + fn embedded_images_past_the_pixel_budget_are_not_decoded_into_it() { + // An 8×8 embedded PNG costs 256 bytes decoded and 256 more resampled. + let png = { + let img = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgba, vec![0; 8 * 8 * 4]).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &photocraft_codecs::EncodeOptions::default()).unwrap() + }; + let d = affinity_document(vec![(8, 8, Pixels::Encoded(png))]); + let mut b = Builder::new("budget", &d).unwrap(); + b.pixel_budget = 8 * 8 * 4 - 1; + b.document(&d).unwrap(); + assert!(b.doc.layers.is_empty(), "the embedded image must not import past the budget"); + assert!(b.warnings.iter().any(|w| w.contains("pixel memory")), "{:?}", b.warnings); + // The same image imports when the budget covers the decode and the resample. + let mut ok = Builder::new("room", &d).unwrap(); + ok.pixel_budget = 8 * 8 * 4 * 2; + ok.document(&d).unwrap(); + assert_eq!(ok.doc.layers.len(), 1, "within the budget the image imports as a smart object"); + } +} diff --git a/crates/io/src/asl_map.rs b/crates/io/src/asl_map.rs new file mode 100644 index 0000000000..76e9e11757 --- /dev/null +++ b/crates/io/src/asl_map.rs @@ -0,0 +1,410 @@ +//! Layer style libraries (`.asl`) ⇄ PhotoCraft styles. +//! +//! The container is parsed by [`photocraft_psd::asl`]. Each style's `Lefx` is the same effects +//! tree as a layer's `lfx2` block ([`crate::effects_map`]); `blendOptions` ("Include Layer +//! Blending Options") carries the blend mode, opacity and fill opacity; the file's patterns feed +//! the styles' Pattern Overlays, pattern strokes and bevel textures. +//! +//! What PhotoCraft doesn't model (an effect kind it doesn't know, advanced blending keys, an +//! effects scale other than 100 %) is left out with a warning naming the style, and the rest of +//! the file still imports. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use photocraft_color::BlendMode; +use photocraft_doc::{Effect, FxPaint, Pattern}; +use photocraft_psd::asl::{self, AslFile}; +use photocraft_psd::descriptor::{Descriptor, Id, Value}; + +use crate::blocks::{enum_of, get_desc, num}; +use crate::effects_map::{blend_from_id, blend_value, effects_descriptor, effects_from_descriptor, is_known_effects_key}; +use crate::pattern_map::{from_psd, to_psd}; + +/// One style as PhotoCraft keeps it. +#[derive(Clone, Debug, PartialEq)] +pub struct AslStyle { + /// Unique within the file (empty names get "Style N", repeats get "Name 2"). + pub name: String, + /// The effects (switched off when the style's effects were). + pub effects: Vec, + /// Blending options, when the style was saved with them. + pub blend: Option, + /// 0..1. + pub opacity: Option, + /// 0..1. + pub fill_opacity: Option, +} + +/// The result of reading a library. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AslImport { + pub styles: Vec, + /// Patterns embedded in the file. + pub patterns: Vec, + /// What was left out, for the user. + pub warnings: Vec, +} + +/// Patterns an effect list refers to: `(name, id)` in first-use order, without repeats. +pub fn referenced_patterns(effects: &[Effect]) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + let mut push = |name: &str, id: &str| { + if !out.iter().any(|(n, i)| if id.is_empty() { n == name } else { i == id }) { + out.push((name.to_string(), id.to_string())); + } + }; + for e in effects { + match e { + Effect::PatternOverlay { name, id, .. } => push(name, id), + Effect::Stroke(s) => { + if let FxPaint::Pattern { name, id, .. } = &s.paint { + push(name, id); + } + } + Effect::BevelEmboss(b) => { + if let Some(t) = &b.texture { + push(&t.name, &t.id); + } + } + _ => {} + } + } + out +} + +fn pct(d: &Descriptor, key: &str) -> Option { + num(d.get(key)).filter(|v| v.is_finite()).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)) +} + +/// Style names in use within one file (ASCII-lowercased), with the next number to try for each +/// repeated name, so a file of identical names stays linear. +#[derive(Default)] +struct Taken { + names: HashSet, + next: HashMap, +} + +impl Taken { + /// `name`, else `name 2`, `name 3`… (case-insensitive); in use from then on. + fn unique(&mut self, name: &str) -> String { + let key = name.to_ascii_lowercase(); + if self.names.insert(key.clone()) { + return name.to_string(); + } + let from = self.next.get(&key).copied().unwrap_or(2); + let i = (from..).find(|i| !self.names.contains(&format!("{key} {i}"))).unwrap_or(from); + self.names.insert(format!("{key} {i}")); + self.next.insert(key, i + 1); + format!("{name} {i}") + } +} + +/// Reads an `.asl` style library. +pub fn read_asl(bytes: &[u8]) -> Result { + let file = asl::parse(bytes).map_err(|e| format!("not a readable style library (.asl): {e}"))?; + let mut out = AslImport { patterns: file.patterns.iter().filter_map(from_psd).collect(), ..Default::default() }; + if let Some(e) = &file.pattern_error { + out.warnings.push(format!("the file's patterns could not be read and were skipped ({e})")); + } + let skipped_patterns = file.patterns.len().saturating_sub(out.patterns.len()); + if skipped_patterns > 0 { + out.warnings.push(format!("{skipped_patterns} empty or unreadable patterns were skipped")); + } + let mut taken = Taken::default(); + let mut missing: BTreeSet = BTreeSet::new(); + for (i, s) in file.styles.iter().enumerate() { + let base = if s.name.trim().is_empty() { format!("Style {}", i + 1) } else { s.name.trim().to_string() }; + let name = taken.unique(&base); + let mut style = AslStyle { name: name.clone(), effects: Vec::new(), blend: None, opacity: None, fill_opacity: None }; + let mut left_out: Vec = Vec::new(); + if let Some(fx) = get_desc(&s.style, "Lefx") { + let (master, mut effects) = effects_from_descriptor(fx); + if !master { + effects.iter_mut().for_each(|e| e.set_enabled(false)); + } + for (k, _) in &fx.items { + if !is_known_effects_key(k.as_bytes()) { + left_out.push(String::from_utf8_lossy(k.as_bytes()).trim().to_string()); + } + } + if let Some(scale) = num(fx.get("Scl ")).filter(|v| v.is_finite() && (v - 100.0).abs() > 0.01) { + left_out.push(format!("effects scale {scale:.0} %")); + } + style.effects = effects; + } + if let Some(b) = get_desc(&s.style, "blendOptions") { + style.blend = enum_of(b, "Md ").and_then(blend_from_id); + style.opacity = pct(b, "Opct"); + style.fill_opacity = pct(b, "fillOpacity"); + for (k, _) in &b.items { + if !matches!(k.as_bytes(), b"Md " | b"Opct" | b"fillOpacity") { + left_out.push(format!("blending option {}", String::from_utf8_lossy(k.as_bytes()).trim())); + } + } + } + for (pname, pid) in referenced_patterns(&style.effects) { + if !out.patterns.iter().any(|p| if pid.is_empty() { p.name == pname } else { p.id == pid }) { + missing.insert(if pname.is_empty() { pid } else { pname }); + } + } + if !left_out.is_empty() { + out.warnings.push(format!("\"{name}\": left out {}", left_out.join(", "))); + } + out.styles.push(style); + } + if !missing.is_empty() { + let list: Vec = missing.into_iter().collect(); + out.warnings.push(format!("patterns not in the file (they must be in the pattern library to render): {}", list.join(", "))); + } + Ok(out) +} + +/// A stable UUID-shaped id for a style (Photoshop gives every style one). +fn style_id(index: usize, name: &str) -> String { + // FNV-1a over the name, mixed with the index: deterministic output for deterministic tests. + let mut h: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d; + for b in name.bytes().chain(index.to_le_bytes()) { + h ^= u128::from(b); + h = h.wrapping_mul(0x0000_0000_0100_0000_0000_0000_0000_013b); + } + let x = format!("{h:032x}"); + let p = |a: usize, b: usize| x.get(a..b).unwrap_or("0"); + format!("{}-{}-{}-{}-{}", p(0, 8), p(8, 12), p(12, 16), p(16, 20), p(20, 32)) +} + +/// Writes styles (and the patterns their effects use) as an `.asl` style library. +pub fn write_asl(styles: &[AslStyle], patterns: &[Pattern]) -> Result, String> { + let pct = |v: f32| Value::UnitFloat { unit: *b"#Prc", value: f64::from(v.clamp(0.0, 1.0)) * 100.0 }; + let file = AslFile { + patterns: patterns.iter().map(to_psd).collect(), + pattern_error: None, + styles: styles + .iter() + .enumerate() + .map(|(i, s)| { + // A style library's effects object has class `Lefx` (a layer's `lfx2` uses `null`). + let mut lefx = effects_descriptor(true, &s.effects); + lefx.class_id = Id::new("Lefx"); + let mut d = + Descriptor::new("Styl").with("documentMode", Value::Descriptor(Descriptor::new("documentMode"))).with("Lefx", Value::Descriptor(lefx)); + if s.blend.is_some() || s.opacity.is_some() || s.fill_opacity.is_some() { + d = d.with( + "blendOptions", + Value::Descriptor( + Descriptor::new("blendOptions") + .with("Md ", blend_value(s.blend.unwrap_or(BlendMode::Normal))) + .with("Opct", pct(s.opacity.unwrap_or(1.0))) + .with("fillOpacity", pct(s.fill_opacity.unwrap_or(1.0))), + ), + ); + } + asl::AslStyle { name: s.name.clone(), id: style_id(i, &s.name), style: d } + }) + .collect(), + }; + asl::write(&file).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; + use photocraft_doc::{BevelTexture, FxCommon, StrokePosition}; + use photocraft_psd::descriptor::UnicodeString; + use photocraft_raster::Surface; + + fn pattern(id: &str, s: SampleType) -> Pattern { + let fmt = PixelFormat::new(ColorMode::Rgb, s, true); + let mut surf = Surface::new(fmt); + for y in 0..3 { + for x in 0..4 { + surf.write_pixel(x, y, &[x as f32 / 4.0, y as f32 / 3.0, 0.5, 1.0]); + } + } + let mut p = Pattern::new("Grid", surf, 4, 3); + p.id = id.into(); + p + } + + /// Every effect kind with its descriptor defaults (the reader fills in what's absent). + fn defaults() -> Vec { + let d = |k: &str| Value::Descriptor(Descriptor::new(k)); + let lefx = Descriptor::new("null") + .with("DrSh", d("DrSh")) + .with("IrSh", d("IrSh")) + .with("OrGl", d("OrGl")) + .with("IrGl", d("IrGl")) + .with("ebbl", d("ebbl")) + .with("ChFX", d("ChFX")) + .with("SoFi", d("SoFi")) + .with("GrFl", d("GrFl")) + .with("FrFX", d("FrFX")); + effects_from_descriptor(&lefx).1 + } + + fn styles() -> Vec { + let mut fx = defaults(); + assert_eq!(fx.len(), 9); + for e in &mut fx { + match e { + Effect::DropShadow(s) => (s.distance, s.size, s.color) = (7.0, 9.0, Color::rgb(0.2, 0.1, 0.0)), + Effect::Stroke(s) => (s.size, s.position, s.paint) = (4.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(0.0, 0.0, 1.0))), + Effect::ColorOverlay { common, color } => (*common, *color) = (FxCommon::new(BlendMode::Multiply, 0.5), Color::rgb(1.0, 0.5, 0.0)), + _ => {} + } + } + let mut bevel = defaults().into_iter().find(|e| matches!(e, Effect::BevelEmboss(_))).unwrap(); + if let Effect::BevelEmboss(b) = &mut bevel { + b.size = 6.0; + b.texture = Some(BevelTexture { name: "Grid".into(), id: "pat-2".into(), scale: 0.5, depth: 1.0, invert: false, link: true, phase: (0.0, 0.0) }); + } + vec![ + AslStyle { name: "Gold".into(), effects: fx, blend: Some(BlendMode::Screen), opacity: Some(0.8), fill_opacity: Some(0.25) }, + AslStyle { + name: "Textured".into(), + effects: vec![ + bevel, + Effect::PatternOverlay { + common: FxCommon { enabled: true, blend: BlendMode::Normal, opacity: 1.0 }, + name: "Grid".into(), + id: "pat-1".into(), + scale: 2.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }, + ], + blend: None, + opacity: None, + fill_opacity: None, + }, + ] + } + + #[test] + fn round_trip_keeps_effects_blending_and_patterns() { + let pats = vec![pattern("pat-1", SampleType::U8), pattern("pat-2", SampleType::U16)]; + let bytes = write_asl(&styles(), &pats).unwrap(); + let back = read_asl(&bytes).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + assert_eq!(back.patterns, pats); + assert_eq!(back.styles.len(), 2); + let (a, b) = (&back.styles[0], &styles()[0]); + assert_eq!((a.blend, a.opacity, a.fill_opacity), (b.blend, b.opacity, b.fill_opacity)); + // Effects go through the same descriptor writer and reader as PSD `lfx2`. + let via_lfx2 = |fx: &[Effect]| effects_from_descriptor(&effects_descriptor(true, fx)).1; + for (x, y) in back.styles.iter().zip(styles()) { + assert_eq!(x.name, y.name); + assert_eq!(x.effects, via_lfx2(&y.effects)); + assert_eq!(x.effects.len(), y.effects.len()); + } + assert_eq!(referenced_patterns(&back.styles[1].effects), vec![("Grid".to_string(), "pat-2".to_string()), ("Grid".to_string(), "pat-1".to_string())]); + } + + /// A library as Photoshop lays it out, built by hand from descriptors. + fn synthetic(lefx: Descriptor, blend: Option, names: &[&str]) -> Vec { + let mut style = Descriptor::new("Styl").with("documentMode", Value::Descriptor(Descriptor::new("documentMode"))).with("Lefx", Value::Descriptor(lefx)); + if let Some(b) = blend { + style = style.with("blendOptions", Value::Descriptor(b)); + } + let file = AslFile { + patterns: vec![], + pattern_error: None, + styles: names.iter().map(|n| asl::AslStyle { name: (*n).into(), id: "x".into(), style: style.clone() }).collect(), + }; + asl::write(&file).unwrap() + } + + #[test] + fn blend_options_names_and_warnings() { + let en = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let pc = |v: f64| Value::UnitFloat { unit: *b"#Prc", value: v }; + let lefx = Descriptor::new("null") + .with("Scl ", pc(100.0)) + .with("masterFXSwitch", Value::Boolean(false)) + .with( + "SoFi", + Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Md ", en("BlnM", "Nrml")).with("Opct", pc(100.0)).with( + "Clr ", + Value::Descriptor( + Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)), + ), + )), + ) + .with("futureFX", Value::Descriptor(Descriptor::new("futureFX"))); + let blend = Descriptor::new("blendOptions") + .with("Md ", en("BlnM", "multiply")) + .with("Opct", pc(40.0)) + .with("fillOpacity", pc(0.0)) + .with("knockout", Value::Integer(1)); + let bytes = synthetic(lefx, Some(blend), &["Red", "", "red", " "]); + let imp = read_asl(&bytes).unwrap(); + let names: Vec<&str> = imp.styles.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Red", "Style 2", "red 2", "Style 4"]); + let s = &imp.styles[0]; + assert_eq!((s.blend, s.opacity, s.fill_opacity), (Some(BlendMode::Multiply), Some(0.4), Some(0.0))); + assert_eq!(s.effects.len(), 1); + // The style's effects were switched off. + assert!(!s.effects[0].enabled()); + assert!(imp.warnings.iter().any(|w| w.contains("futureFX") && w.contains("knockout")), "{:?}", imp.warnings); + } + + #[test] + fn missing_patterns_are_reported() { + let po = Descriptor::new("patternFill").with("enab", Value::Boolean(true)).with( + "Ptrn", + Value::Descriptor( + Descriptor::new("Ptrn").with("Nm ", Value::Text(UnicodeString::new_nul("Bubbles"))).with("Idnt", Value::Text(UnicodeString::new_nul("b-1"))), + ), + ); + let bytes = synthetic(Descriptor::new("null").with("patternFill", Value::Descriptor(po)), None, &["P"]); + let imp = read_asl(&bytes).unwrap(); + assert_eq!(imp.styles[0].effects.len(), 1); + assert_eq!(imp.styles[0].blend, None); + assert!(imp.warnings.iter().any(|w| w.contains("Bubbles")), "{:?}", imp.warnings); + } + + #[test] + fn hostile_bytes_are_errors() { + let bytes = write_asl(&styles(), &[pattern("pat-1", SampleType::U8)]).unwrap(); + for cut in 0..bytes.len() { + assert!(read_asl(&bytes[..cut]).is_err(), "cut {cut}"); + } + for i in 0..bytes.len() { + let mut c = bytes.clone(); + c[i] ^= 0xa5; + let _ = read_asl(&c); + } + assert!(read_asl(b"").is_err()); + assert!(read_asl(&photocraft_psd::grd::write(&[])).is_err()); + } + + #[test] + fn exported_effects_object_has_class_lefx() { + let bytes = write_asl(&styles(), &[]).unwrap(); + let file = asl::parse(&bytes).unwrap(); + for s in &file.styles { + let lefx = get_desc(&s.style, "Lefx").unwrap(); + assert_eq!(lefx.class_id, Id::new("Lefx"), "{}", s.name); + } + } + + #[test] + fn many_identical_names_import_quickly() { + let names = vec!["A"; asl::MAX_STYLES]; + let bytes = synthetic(Descriptor::new("Lefx"), None, &names); + let start = std::time::Instant::now(); + let imp = read_asl(&bytes).unwrap(); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert_eq!(imp.styles.len(), asl::MAX_STYLES); + assert_eq!(imp.styles[1].name, "A 2"); + assert_eq!(imp.styles.last().unwrap().name, format!("A {}", asl::MAX_STYLES)); + } + + #[test] + fn style_ids_are_uuid_shaped() { + let id = style_id(3, "Gold"); + assert_eq!(id.len(), 36); + assert_ne!(id, style_id(4, "Gold")); + } +} diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 1ae5e1fffa..0cfe0b5e8e 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -182,6 +182,17 @@ pub fn color_from_desc(d: &Descriptor) -> Option { Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0) } }) + .map(|c| with_pc_alpha(c, d)) +} + +/// PSD colour descriptors have no alpha. PhotoCraft writes `pcAl` (0..1) so shape fill +/// opacity round-trips (#6180). Photoshop ignores the key. +fn with_pc_alpha(mut c: Color, d: &Descriptor) -> Color { + // A hostile file's NaN or infinity would survive the clamp: keep the colour opaque instead. + if let Some(a) = num(d.get("pcAl")).filter(|a| a.is_finite()) { + c.alpha = (a as f32).clamp(0.0, 1.0); + } + c } /// [`Color`] → descriptor in its own model. @@ -204,6 +215,13 @@ pub fn color_to_desc(c: &Color) -> Descriptor { } } +/// Solid shape fills have no other alpha slot. Write `pcAl` only here so gradient stops +/// keep using `Trns` alone (#6180). +fn color_to_desc_with_pc_alpha(c: &Color) -> Descriptor { + let desc = color_to_desc(c); + if c.alpha.is_finite() && (c.alpha - 1.0).abs() > 1e-6 { desc.with("pcAl", Value::Double(f64::from(c.alpha.clamp(0.0, 1.0)))) } else { desc } +} + pub(crate) fn get_desc<'a>(d: &'a Descriptor, key: &str) -> Option<&'a Descriptor> { match d.get(key)? { Value::Descriptor(x) | Value::GlobalObject(x) => Some(x), @@ -499,7 +517,7 @@ pub fn write_fill(f: &Fill) -> ([u8; 4], Vec) { /// A fill as (block key, descriptor with class `null`). pub fn fill_to_desc(f: &Fill) -> ([u8; 4], Descriptor) { match f { - Fill::Solid(c) => (*b"SoCo", Descriptor::new("null").with("Clr ", Value::Descriptor(color_to_desc(c)))), + Fill::Solid(c) => (*b"SoCo", Descriptor::new("null").with("Clr ", Value::Descriptor(color_to_desc_with_pc_alpha(c)))), Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => { let prc = |v: f32| Value::UnitFloat { unit: *b"#Prc", value: f64::from(v * 100.0) }; let mut d = Descriptor::new("null") @@ -568,9 +586,10 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, /// The warp of a placed layer (`SoLd`/`SoLE` `quiltWarp` or `warp` descriptor): style, bend, /// distortions, axis, bounds and, for `warpCustom`, the `customEnvelopeWarp` mesh: one 4 × 4 -/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`. Read-only: the -/// raw block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` -/// record keeps its warp elsewhere: see [`parse_plld_warp`]. +/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`; for +/// `warpCylinder` its seven `warpValues`. Read-only: the raw block is what gets written back. +/// `None` when absent or `warpNone` with no mesh. A `PlLd` record keeps its warp elsewhere: see +/// [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { if key != b"SoLd" && key != b"SoLE" { return None; @@ -583,7 +602,8 @@ pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option Option> { use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; @@ -597,6 +617,17 @@ pub(crate) fn warp_of(w: &Descriptor) -> Option = match w.get("warpValues")? { + Value::List(l) => l.iter().map(|v| num(Some(v))).collect::>()?, + Value::UnitFloats { values, .. } => values.clone(), + _ => return None, + }; + return Some(Some(Warp::cylinder(&values, bounds)?)); + } if style == WarpStyle::Custom { let env = get_desc(w, "customEnvelopeWarp")?; let Some(Value::ObjectArray(arr)) = env.get("meshPoints") else { return None }; @@ -702,12 +733,14 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option { } /// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a -/// custom warp's control points (one patch or a split grid), which may spill past the source, -/// else the source rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice -/// and stretches the placement. A degenerate or non-finite box falls back to the source rect. +/// custom warp's control points (one patch or a split grid) or of a cylinder's mesh (#4112), +/// which may spill past the source, else the source rect `0..w × 0..h`. Mapping the source rect +/// instead counts the overhang twice and stretches the placement. A degenerate or non-finite box +/// falls back to the source rect. pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { + use photocraft_geom::warp::WarpStyle; let rect = [0.0, 0.0, w, h]; - let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.is_valid()) else { + let Some(m) = warp.filter(|wp| matches!(wp.style, WarpStyle::Custom | WarpStyle::Cylinder)).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.is_valid()) else { return rect; }; let inf = f64::INFINITY; @@ -716,6 +749,16 @@ pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h if ok { b } else { rect } } +/// The source-space box a projective (Distort / Perspective) placement maps onto its corners: a +/// cylinder's control-point box, as for an affine placement (#4112), else the source rect. A +/// custom warp keeps the source rect here: there is no Photoshop reference for that combination. +pub(crate) fn perspective_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { + match warp { + Some(wp) if wp.style == photocraft_geom::warp::WarpStyle::Cylinder => placed_frame(warp, w, h), + _ => [0.0, 0.0, w, h], + } +} + /// Smart object identifier and transform from `SoLd`/`SoLE` (best effort). A `PlLd` block /// stores no source size, so its transform needs the embedded file: see [`parse_plld`]. pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { @@ -753,17 +796,17 @@ pub fn corners_affine(p: &[f64; 8], frame: [f64; 4]) -> Option { /// The transform and (Distort/Perspective) projective placement of a `PlLd` record's corners `p` /// for a source of `size` warped by `warp`: the inverse of [`crate::smart_map::plld_bytes`]'s -/// quad. As for `SoLd`'s `Trnf` (#3677), affine corners frame a single-patch custom warp's +/// quad. As for `SoLd`'s `Trnf` (#3677), affine corners frame a custom warp's or a cylinder's /// control-point box (which may spill past the source), else the source rect; projective corners -/// frame the source rect, as `parse_smart_perspective` reads them. `PlLd` stores no source size: -/// without the source file (`None`) the layer is taken as placed at 100 %, the edge lengths being -/// its size (a custom warp's box needs no size); position and rotation stay right, only Replace -/// Contents and re-renders depend on the scale. Import and export (which keeps an unedited record -/// verbatim) both place a `PlLd` layer with this. +/// frame [`perspective_frame`], as `parse_smart_perspective` reads them. `PlLd` stores no source +/// size: without the source file (`None`) the layer is taken as placed at 100 %, the edge lengths +/// being its size (a custom warp's box needs no size); position and rotation stay right, only +/// Replace Contents and re-renders depend on the scale. Import and export (which keeps an unedited +/// record verbatim) both place a `PlLd` layer with this. pub fn plld_transform(p: &[f64; 8], size: Option<(f64, f64)>, warp: Option<&photocraft_geom::warp::Warp>) -> (Affine, Option<[f64; 9]>) { let (w, h) = size.unwrap_or_else(|| ((p[2] - p[0]).hypot(p[3] - p[1]), (p[6] - p[0]).hypot(p[7] - p[1]))); - let perspective = corners_perspective([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], (w, h)); - let frame = if perspective.is_some() { [0.0, 0.0, w, h] } else { placed_frame(warp, w, h) }; + let perspective = corners_perspective_in([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], perspective_frame(warp, w, h)); + let frame = if perspective.is_some() { perspective_frame(warp, w, h) } else { placed_frame(warp, w, h) }; (corners_affine(p, frame).unwrap_or(Affine::IDENTITY), perspective) } @@ -838,18 +881,27 @@ pub fn parse_smart_perspective(key: &[u8; 4], data: &[u8]) -> Option<[f64; 9]> { (p.len() == 8 && p.iter().all(|v| v.is_finite())).then(|| [[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]]) }; let q = quad("nonAffineTransform").or_else(|| quad("Trnf"))?; - corners_perspective(q, (w, h)) + // The warp `parse_placed_warp` reads: a split warp's `quiltWarp` comes first. + let warp = get_desc(&d, "quiltWarp").or_else(|| get_desc(&d, "warp")).and_then(warp_of).flatten(); + corners_perspective_in(q, perspective_frame(warp.as_ref(), w, h)) } /// The projective placement of a `w` × `h` source onto corners `q` that aren't a parallelogram; /// `None` for affine corners (see [`corners_affine`]) or an empty size. pub fn corners_perspective(q: [[f64; 2]; 4], (w, h): (f64, f64)) -> Option<[f64; 9]> { + corners_perspective_in(q, [0.0, 0.0, w, h]) +} + +/// [`corners_perspective`] for the source-space box `frame` (left, top, right, bottom; see +/// [`perspective_frame`]). +pub(crate) fn corners_perspective_in(q: [[f64; 2]; 4], frame: [f64; 4]) -> Option<[f64; 9]> { // A parallelogram (top-left + bottom-right = top-right + bottom-left) is affine. let tol = 1e-6 * (1.0 + q.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()))); - if (0..2).all(|i| (q[0][i] + q[2][i] - q[1][i] - q[3][i]).abs() <= tol) || w <= 0.0 || h <= 0.0 { + let ok = frame.iter().all(|v| v.is_finite()) && frame[2] > frame[0] && frame[3] > frame[1]; + if (0..2).all(|i| (q[0][i] + q[2][i] - q[1][i] - q[3][i]).abs() <= tol) || !ok { return None; } - photocraft_algo::transform::Homography::rect_to_quad([0.0, 0.0, w, h], q).map(|m| m.0) + photocraft_algo::transform::Homography::rect_to_quad(frame, q).map(|m| m.0) } /// `masterFXSwitch` from an `lfx2` block (defaults to `true`). @@ -991,6 +1043,55 @@ mod tests { assert!(super::color_from_desc(&Descriptor::new("HSBC").with("Strt", Value::Double(50.0))).is_none()); } + #[test] + fn color_descriptor_keeps_photocraft_alpha() { + use photocraft_color::Color; + use photocraft_doc::Fill; + let c = Color::rgba(0.2, 0.4, 0.6, 0.25); + let (k, d) = super::fill_to_desc(&Fill::Solid(c)); + let Fill::Solid(back) = super::fill_from_desc(&k, &d).unwrap() else { panic!("solid") }; + assert!((back.alpha - 0.25).abs() < 1e-5); + assert!((back.to_rgb()[0] - 0.2).abs() < 1e-3); + let (k, d) = super::fill_to_desc(&Fill::Solid(Color::rgb(1.0, 0.0, 0.0))); + let Fill::Solid(opaque) = super::fill_from_desc(&k, &d).unwrap() else { panic!("solid") }; + assert!((opaque.alpha - 1.0).abs() < 1e-6); + assert!(super::color_to_desc(&c).get("pcAl").is_none(), "generic colour descriptors have no pcAl"); + } + + #[test] + fn non_finite_photocraft_alpha_is_ignored() { + use photocraft_color::Color; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let d = super::color_to_desc(&Color::rgb(0.2, 0.4, 0.6)).with("pcAl", Value::Double(bad)); + let c = super::color_from_desc(&d).unwrap(); + assert_eq!(c.alpha, 1.0, "pcAl {bad}"); + } + let d = super::color_to_desc(&Color::rgb(0.2, 0.4, 0.6)).with("pcAl", Value::Double(0.5)); + assert!((super::color_from_desc(&d).unwrap().alpha - 0.5).abs() < 1e-6); + } + + #[test] + fn gradient_stop_alpha_round_trips_once() { + use photocraft_color::Color; + use photocraft_doc::Fill; + let fill = Fill::Gradient { + stops: vec![(0.0, Color::rgba(1.0, 0.0, 0.0, 0.5)), (1.0, Color::WHITE)], + angle: 90.0, + scale: 1.0, + style: photocraft_doc::GradientStyle::Linear, + reverse: false, + opacity_stops: vec![], + midpoints: vec![], + offset: (0.0, 0.0), + dither: false, + align: true, + }; + let (k, d) = super::fill_to_desc(&fill); + let Fill::Gradient { stops, opacity_stops, .. } = super::fill_from_desc(&k, &d).unwrap() else { panic!("grad") }; + assert!((stops[0].1.alpha - 1.0).abs() < 1e-4, "colour alpha lives in Trns, not the stop colour"); + assert!(opacity_stops.iter().any(|(_, a)| (*a - 0.5).abs() < 1e-3), "{opacity_stops:?}"); + } + #[test] fn blending_ranges_map_to_blend_if() { use photocraft_doc::{BlendIf, BlendRange}; @@ -1517,6 +1618,143 @@ mod more_tests { assert_eq!(placed_frame(None, 50.0, 25.0), [0.0, 0.0, 50.0, 25.0]); } + /// A `SoLd` placed on `quad` (`Trnf` and `nonAffineTransform`) for a 1000 × 1000 source, + /// warped by `warp`. + fn sold_placed(quad: [f64; 8], warp: Descriptor) -> Vec { + let list = Value::List(quad.iter().map(|v| Value::Double(*v)).collect()); + let d = Descriptor::new("null") + .with("Trnf", list.clone()) + .with("nonAffineTransform", list) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(1000.0)).with("Hght", Value::Double(1000.0)))) + .with("warp", Value::Descriptor(warp)); + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + data + } + + /// A `warpCylinder` descriptor as #4112 describes it: horizontal, no perspective, seven + /// `warpValues` in the space of `bounds`. + fn cylinder_desc(values: Value) -> Descriptor { + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let bounds = Descriptor::new("classFloatRect") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(1000.0)) + .with("Rght", Value::Double(1000.0)); + Descriptor::new("warp") + .with("warpStyle", e("warpStyle", "warpCylinder")) + .with("warpValue", Value::Double(0.0)) + .with("warpValues", values) + .with("warpPerspective", Value::Double(0.0)) + .with("warpPerspectiveOther", Value::Double(0.0)) + .with("warpRotate", e("Ornt", "Hrzn")) + .with("bounds", Value::Descriptor(bounds)) + } + + const CYL: [f64; 7] = [100.0, 800.0, 900.0, 200.0, 0.3, 0.3, 0.5]; + + fn doubles(v: &[f64]) -> Value { + Value::List(v.iter().map(|x| Value::Double(*x)).collect()) + } + + /// A projective placement is framed by the warp `parse_placed_warp` imports: an unreadable + /// `quiltWarp` next to a cylinder under `warp` imports no warp, so the source rect is framed. + #[test] + fn perspective_frame_follows_the_warp_parse_placed_warp_reads() { + let quad = [10.0, 20.0, 410.0, 60.0, 400.0, 480.0, 30.0, 520.0]; + let list = Value::List(quad.iter().map(|v| Value::Double(*v)).collect()); + let unreadable = + Descriptor::new("quiltWarp").with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpSomethingNew") }); + let d = Descriptor::new("null") + .with("Trnf", list.clone()) + .with("nonAffineTransform", list) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(1000.0)).with("Hght", Value::Double(1000.0)))) + .with("quiltWarp", Value::Descriptor(unreadable)) + .with("warp", Value::Descriptor(cylinder_desc(doubles(&CYL)))); + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + assert_eq!(parse_placed_warp(b"SoLd", &data), None); + let h = photocraft_algo::transform::Homography(parse_smart_perspective(b"SoLd", &data).expect("projective")); + let (px, py) = h.apply(1000.0, 1000.0); + assert!((px - 400.0).abs() < 1e-6 && (py - 480.0).abs() < 1e-6, "{px},{py}"); + } + /// #4112: a `warpCylinder` placed layer comes in as the cylinder its seven values describe, + /// instead of no warp (which re-rendered the contents flat). + #[test] + fn placed_cylinder_warp_is_read() { + use photocraft_geom::warp::{Warp, WarpStyle}; + let quad = [0.0, 0.0, 800.0, 0.0, 800.0, 800.0, 0.0, 800.0]; + let want = Warp::cylinder(&CYL, [0.0, 0.0, 1000.0, 1000.0]).unwrap(); + let w = parse_placed_warp(b"SoLd", &sold_placed(quad, cylinder_desc(doubles(&CYL)))).expect("a cylinder"); + assert_eq!(w, want); + assert_eq!(w.style, WarpStyle::Cylinder); + // Values written as one unit-float list read the same. + let floats = Value::UnitFloats { unit: *b"#Pxl", values: CYL.to_vec() }; + assert_eq!(parse_placed_warp(b"SoLE", &sold_placed(quad, cylinder_desc(floats))), Some(want)); + } + + /// Cylinders nobody has measured (vertical, with perspective) and broken values stay + /// unreadable, as every cylinder was before: the layer keeps the pixels Photoshop stored. + #[test] + fn placed_cylinder_warps_that_cant_be_read() { + let quad = [0.0, 0.0, 800.0, 0.0, 800.0, 800.0, 0.0, 800.0]; + let read = |d: Descriptor| parse_placed_warp(b"SoLd", &sold_placed(quad, d)); + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let with = |key: &str, v: Value| { + let mut d = cylinder_desc(doubles(&CYL)); + d.items.iter_mut().filter(|(k, _)| k.is(key)).for_each(|(_, old)| *old = v.clone()); + d + }; + assert!(read(with("warpRotate", e("Ornt", "Vrtc"))).is_none(), "vertical"); + assert!(read(with("warpPerspective", Value::Double(20.0))).is_none(), "perspective"); + assert!(read(with("warpPerspectiveOther", Value::Double(-5.0))).is_none(), "the other perspective"); + assert!(read(cylinder_desc(doubles(&CYL[..6]))).is_none(), "six values"); + assert!(read(cylinder_desc(Value::List(vec![Value::Text(UnicodeString::new_nul("x")); 7]))).is_none(), "not numbers"); + assert!(read(cylinder_desc(Value::Double(1.0))).is_none(), "one number"); + let mut flipped = CYL; + flipped.swap(0, 2); + assert!(read(cylinder_desc(doubles(&flipped))).is_none(), "silhouettes swapped"); + let mut no_values = cylinder_desc(doubles(&CYL)); + no_values.items.retain(|(k, _)| !k.is("warpValues")); + assert!(read(no_values).is_none(), "no warpValues"); + } + + /// Like a custom warp's (#3677), a cylinder's control-point box is what the placement quad + /// frames, for affine corners and for projective ones (`nonAffineTransform`). + #[test] + fn placed_cylinder_quad_frames_its_control_box() { + use photocraft_geom::warp::Warp; + let cb = Warp::cylinder(&CYL, [0.0, 0.0, 1000.0, 1000.0]).unwrap().mesh.unwrap().control_bounds(); + // Affine: the box lands on the quad. + let quad = [10.0, 20.0, 410.0, 20.0, 410.0, 520.0, 10.0, 520.0]; + let (_, a) = parse_smart(b"SoLd", &sold_placed(quad, cylinder_desc(doubles(&CYL)))); + let [m0, m1, m2, m3, m4, m5] = a.m; + let map = |x: f64, y: f64| (m0 * x + m2 * y + m4, m1 * x + m3 * y + m5); + for ((x, y), (qx, qy)) in + [(cb[0], cb[1]), (cb[2], cb[1]), (cb[2], cb[3]), (cb[0], cb[3])].into_iter().zip(quad.as_chunks::<2>().0.iter().map(|p| (p[0], p[1]))) + { + let (px, py) = map(x, y); + assert!((px - qx).abs() < 1e-9 && (py - qy).abs() < 1e-9, "{x},{y} → {px},{py}, not {qx},{qy}"); + } + assert!(parse_smart_perspective(b"SoLd", &sold_placed(quad, cylinder_desc(doubles(&CYL)))).is_none(), "affine"); + // Projective: the same box lands on the four corners. + let quad = [10.0, 20.0, 410.0, 60.0, 400.0, 480.0, 30.0, 520.0]; + let h = photocraft_algo::transform::Homography(parse_smart_perspective(b"SoLd", &sold_placed(quad, cylinder_desc(doubles(&CYL)))).expect("projective")); + for ((x, y), (qx, qy)) in + [(cb[0], cb[1]), (cb[2], cb[1]), (cb[2], cb[3]), (cb[0], cb[3])].into_iter().zip(quad.as_chunks::<2>().0.iter().map(|p| (p[0], p[1]))) + { + let (px, py) = h.apply(x, y); + assert!((px - qx).abs() < 1e-6 && (py - qy).abs() < 1e-6, "{x},{y} → {px},{py}, not {qx},{qy}"); + } + // Without a warp a projective quad still frames the source rect. + let none = Descriptor::new("warp").with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpNone") }); + let h = photocraft_algo::transform::Homography(parse_smart_perspective(b"SoLd", &sold_placed(quad, none)).expect("projective")); + let (px, py) = h.apply(1000.0, 1000.0); + assert!((px - 400.0).abs() < 1e-6 && (py - 480.0).abs() < 1e-6); + } + #[test] fn effects_switch() { let mut lfx = vec![0, 0, 0, 0]; diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs index 159e298a06..a2e63322d0 100644 --- a/crates/io/src/camera_raw_xmp.rs +++ b/crates/io/src/camera_raw_xmp.rs @@ -3,8 +3,9 @@ //! `x:xmpmeta/rdf:RDF/rdf:Description`, as attributes or as child elements. //! //! Loading applies only the settings the file holds, over the current ones, as Camera Raw's -//! Load Settings does; settings PhotoCraft has no control for (lens corrections, calibration, -//! geometry, defringe, …) are ignored and named in the report. Saving writes every supported +//! Load Settings does; settings PhotoCraft has no control for (lens corrections, geometry, +//! defringe, …) are ignored and named in the report. The same reader fills the dialog from a +//! raw file's sidecar (`IMG_0001.xmp` beside `IMG_0001.CR2`), and the writer keeps it. Saving writes every supported //! control, so loading the file in Photoshop reproduces the dialog. //! //! White balance: a Camera Raw Filter on a rendered image uses the relative @@ -100,6 +101,14 @@ const SCALARS: &[(&str, Field, f32, f32)] = &[ ("PostCropVignetteRoundness", |p| &mut p.vignette_roundness, -100.0, 100.0), ("PostCropVignetteFeather", |p| &mut p.vignette_feather, 0.0, 100.0), ("PostCropVignetteHighlightContrast", |p| &mut p.vignette_highlights, 0.0, 100.0), + // Calibration + ("ShadowTint", |p| &mut p.shadow_tint, -100.0, 100.0), + ("RedHue", |p| &mut p.calibration_hue[0], -100.0, 100.0), + ("RedSaturation", |p| &mut p.calibration_sat[0], -100.0, 100.0), + ("GreenHue", |p| &mut p.calibration_hue[1], -100.0, 100.0), + ("GreenSaturation", |p| &mut p.calibration_sat[1], -100.0, 100.0), + ("BlueHue", |p| &mut p.calibration_hue[2], -100.0, 100.0), + ("BlueSaturation", |p| &mut p.calibration_sat[2], -100.0, 100.0), ]; type Curve = fn(&mut CameraRaw) -> &mut Vec<[f32; 2]>; @@ -265,6 +274,25 @@ fn escape(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } +/// The XMP toolkit [`save`] names on `x:xmpmeta`. +const TOOLKIT: &str = "PhotoCraft"; +const META_NS: &str = "adobe:ns:meta/"; + +/// Did [`save`] write this file? True when its `x:xmpmeta` names PhotoCraft as the toolkit +/// (`x:xmptk`). Lightroom, Bridge and Camera Raw put their own toolkit there when they write a +/// file, so a sidecar another application touched since reads false. Never panics. +pub fn is_photocraft_xmp(bytes: &[u8]) -> bool { + if bytes.len() > MAX_XMP_BYTES { + return false; + } + let Ok(text) = std::str::from_utf8(bytes) else { return false }; + let Ok(doc) = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) else { return false }; + doc.descendants() + .find(|n| n.is_element() && n.tag_name().namespace() == Some(META_NS) && n.tag_name().name() == "xmpmeta") + .and_then(|n| n.attribute((META_NS, "xmptk"))) + == Some(TOOLKIT) +} + /// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads. pub fn save(params: &CameraRaw) -> String { let mut p = params.clone(); @@ -357,7 +385,8 @@ mod tests { assert_eq!(p.point_curve_red, vec![[0.0, 0.0], [128.0, 140.0], [255.0, 255.0]]); assert_eq!(p.dehaze, 12.0); // Lens vignetting, calibration and geometry have no counterpart. - assert_eq!(r.ignored, vec!["VignetteAmount".to_string(), "RedHue".to_string()]); + assert_eq!(r.ignored, vec!["VignetteAmount".to_string()], "the Calibration panel's keys load now"); + assert_eq!(p.calibration_hue[0], -5.0); } #[test] @@ -420,4 +449,24 @@ mod tests { assert_eq!(p, before); assert_eq!(r.ignored, vec!["Contrast2012".to_string(), "ToneCurvePV2012".to_string()]); } + + #[test] + fn is_photocraft_xmp_reads_the_toolkit_marker() { + let own = save(&CameraRaw { contrast: 15.0, ..CameraRaw::default() }); + assert!(is_photocraft_xmp(own.as_bytes())); + let wrapped = format!("\u{feff}\n{own}"); + assert!(is_photocraft_xmp(wrapped.as_bytes()), "an xpacket wrapper and a byte order mark"); + assert!(!is_photocraft_xmp(FIXTURE), "Camera Raw's own toolkit"); + for other in [ + &b""[..], + b"", + b"", + b"", + b"", + b"\xff\xfe", + ] { + assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other)); + } + } } diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index 74e42feb11..ef3b389bee 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -4,15 +4,18 @@ //! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity //! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected") //! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot -//! kind, each followed by a padding byte (14 bytes). +//! kind, each followed by a padding byte (14 bytes). Photoshop 27's TIFFs use the same 13-byte +//! 1077 records. -use photocraft_color::{Color, ColorMode}; +use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_doc::{AlphaChannel, ColorIndicates}; pub(crate) const DISPLAY_INFO: u16 = 1077; pub(crate) const DISPLAY_INFO_OLD: u16 = 1007; /// Quick Mask information: channel id (u16) and an "initially empty" flag (u8). pub(crate) const QUICK_MASK_INFO: u16 = 1022; +/// Alpha channel identifiers (Photoshop 6): one big-endian u32 per extra channel, no count prefix. +pub(crate) const ALPHA_IDENTIFIERS: u16 = 1053; const SPACE_RGB: u16 = 0; const SPACE_HSB: u16 = 1; @@ -20,6 +23,33 @@ const SPACE_CMYK: u16 = 2; const SPACE_LAB: u16 = 7; const SPACE_GRAY: u16 = 8; +/// Converts one spot channel's plane (native-endian samples) between the document's ink density +/// (1 = solid ink) and the way Photoshop stores a spot channel (0 = solid ink, the maximum = +/// bare paper; every PSD and TIFF Photoshop 27.11 saves stores it so). The conversion is its own +/// inverse, so it serves both open and save. Every reader and writer of Photoshop-polarity spot +/// planes goes through here: a change to the in-memory convention changes only this function. +/// Alpha channels are stored straight (the maximum = selected) and never come here. +pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) { + crate::pixels::invert_plane(plane, sample); +} + +/// Resource 1053 for `count` channels: one big-endian u32 identifier each, with no length prefix +/// (Photoshop, ag-psd and psd-tools read u32s until the resource ends). Photoshop 27.11 numbers a +/// new document's channels 3, 4, 5, … in every colour mode (RGB, CMYK, Gray and Lab, in its PSDs +/// and TIFFs alike), so these do too. `transparency`: the merged image's transparency channel +/// comes first, with identifier 0 (Photoshop's transparent PSD lists `0, 3, 4, 5`). +pub(crate) fn alpha_identifiers(transparency: bool, count: usize) -> Vec { + let mut out = Vec::with_capacity(count.saturating_add(1).saturating_mul(4)); + if transparency { + out.extend_from_slice(&0u32.to_be_bytes()); + } + for i in 0..count { + let id = 3u32.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); + out.extend_from_slice(&id.to_be_bytes()); + } + out +} + /// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour /// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13; /// 14-byte records (the older 1007 layout) shift every channel after the first. @@ -46,12 +76,33 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve out } +/// The records of a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; +/// else the older 1007 with 14-byte records). A trailing partial record is left out. +fn display_records(data: &[u8], versioned: bool) -> std::slice::ChunksExact<'_, u8> { + let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) }; + body.chunks_exact(size) +} + +/// Which of the extra channels are spot channels (kind 2), after skipping `skip` records (the +/// merged transparency channel's), in the same records [`apply_display_info`] reads. A channel +/// without a record is not a spot. +pub(crate) fn spot_flags(data: &[u8], versioned: bool, skip: usize) -> Vec { + display_records(data, versioned).skip(skip).map(|rec| rec.get(12) == Some(&2)).collect() +} + +/// Whether a DisplayInfo resource 1077 marks any channel as a spot (kind 2), in 13-byte records +/// (Photoshop's, and PhotoCraft's since 0.8.2) or in the 14-byte padded records PhotoCraft wrote +/// up to 0.7.0. +pub(crate) fn display_info_marks_spot(data: &[u8]) -> bool { + let body = data.get(4..).unwrap_or_default(); + [13, 14].into_iter().any(|size| body.chunks_exact(size).any(|rec| rec.get(12) == Some(&2))) +} + /// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the /// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip` /// records (the merged transparency channel's). pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) { - let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) }; - for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) { + for (c, rec) in channels.iter_mut().zip(display_records(data, versioned).skip(skip)) { let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]); let comps = [u(2), u(4), u(6), u(8)]; let color = decode_color(u(0), comps); @@ -149,6 +200,15 @@ mod tests { assert!((back[0].opacity - 0.3).abs() < 1e-6); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); + assert_eq!(spot_flags(&bytes, true, 0), [false, true]); + assert!(display_info_marks_spot(&bytes)); + assert!(!display_info_marks_spot(&display_info(&[&a], true)), "alpha and transparency records only"); + assert!(!display_info_marks_spot(&bytes[..10])); + // Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60. + let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2]; + let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; + apply_display_info(&ps, true, 0, &mut spot); + assert_eq!(spot[0].spot.map(|(c, s)| (c.to_rgb(), (s * 100.0).round() as u16)), Some(([1.0, 0.0, 0.0], 60))); // With the merged transparency's record first, the channels still get their own. let with_t = display_info(&[&a, &s], true); assert_eq!(with_t.len(), 4 + 3 * 13); @@ -156,10 +216,79 @@ mod tests { apply_display_info(&with_t, true, 1, &mut back); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); + assert_eq!(spot_flags(&with_t, true, 1), [false, true]); // Truncated data is ignored rather than misread. let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))]; apply_display_info(&bytes[..10], true, 0, &mut c); assert_eq!(c[0].opacity, 0.5); + assert!(spot_flags(&bytes[..10], true, 0).is_empty()); + assert!(spot_flags(&bytes, true, 5).is_empty()); + + // 14 unpadded records are a multiple of 14 as well (14×13 = 13×14). Resource 1077 + // still reads them as 13-byte records; the last spot's solidity must survive. + let mut many = Vec::new(); + for i in 0..14 { + let mut ch = AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8)); + ch.spot = Some((Color::rgb(1.0, 0.0, 0.0), (i as f32) / 14.0)); + many.push(ch); + } + let refs: Vec<&AlphaChannel> = many.iter().collect(); + let packed = display_info(&refs, false); + assert_eq!(packed.len(), 4 + 14 * 13); + let mut got: Vec = (0..14).map(|i| AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8))).collect(); + apply_display_info(&packed, true, 0, &mut got); + assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16)); + } + + /// Resource 1053 as Photoshop 27.11 numbers a new document's channels: 3, 4, 5 in every colour + /// mode, with the merged transparency first as 0 (its transparent PSD lists `0, 3, 4, 5`). + #[test] + fn alpha_identifiers_are_numbered_as_photoshop_does() { + let ids = |transparency, count| alpha_identifiers(transparency, count).as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect::>(); + assert_eq!(ids(false, 3), [3, 4, 5]); + assert_eq!(ids(true, 3), [0, 3, 4, 5]); + assert_eq!(ids(false, 1), [3]); + assert!(ids(false, 0).is_empty()); + assert_eq!(ids(true, 0), [0]); + assert_eq!(alpha_identifiers(false, 2), [0, 0, 0, 3, 0, 0, 0, 4]); + } + + /// PhotoCraft 0.1.0 to 0.7.0 padded each 1077 record to 14 bytes, so a spot after the first + /// record sits where a 13-byte reader finds no kind 2; it is still recognised. + #[test] + fn a_spot_is_found_in_padded_records() { + let alpha = |kind: u8| [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, kind, 0]; + let mut old = 1u32.to_be_bytes().to_vec(); + old.extend_from_slice(&alpha(1)); + old.extend_from_slice(&alpha(0)); + old.extend_from_slice(&alpha(2)); + assert_eq!(spot_flags(&old, true, 0), [false, false, false], "13-byte records misread the padded ones"); + assert!(display_info_marks_spot(&old)); + old.truncate(old.len() - 14); + assert!(!display_info_marks_spot(&old), "two alpha records"); + } + + /// Photoshop's spot ink (0 = solid) and the document's density (1 = solid) at every depth, + /// both ways: the 25 % / 75 % columns of the Photoshop oracles store 191 / 64. + #[test] + fn photoshop_spot_plane_converts_both_ways() { + let mut p8 = vec![255u8, 64, 191, 0]; + photoshop_spot_plane(&mut p8, SampleType::U8); + assert_eq!(p8, [0, 191, 64, 255]); + photoshop_spot_plane(&mut p8, SampleType::U8); + assert_eq!(p8, [255, 64, 191, 0]); + let mut p16: Vec = [65535u16, 16448, 0].iter().flat_map(|v| v.to_ne_bytes()).collect(); + photoshop_spot_plane(&mut p16, SampleType::U16); + let back: Vec = p16.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect(); + assert_eq!(back, [0, 49087, 65535]); + let mut p32: Vec = [1.0f32, 0.25].iter().flat_map(|v| v.to_ne_bytes()).collect(); + photoshop_spot_plane(&mut p32, SampleType::F32); + let back: Vec = p32.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect(); + assert_eq!(back, [0.0, 0.75]); + // A trailing partial sample is left alone rather than misread. + let mut odd = vec![0u8, 0, 7]; + photoshop_spot_plane(&mut odd, SampleType::U16); + assert_eq!(odd, [0xff, 0xff, 7]); } #[test] diff --git a/crates/io/src/doc_path.rs b/crates/io/src/doc_path.rs index 37e7daf214..8c3c36c93d 100644 --- a/crates/io/src/doc_path.rs +++ b/crates/io/src/doc_path.rs @@ -35,13 +35,20 @@ impl std::error::Error for NotFollowed {} /// a relative `document` is taken from the working directory, where it was opened from. pub fn resolve(stored: &str, document: Option<&Path>) -> Result { let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); - let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; - // A path that is still relative would be taken from the working directory, which the - // document does not choose. - if path.is_relative() { - return Err(NotFollowed::NoDocumentFolder); - } - Ok(path) + craft_docpath::resolve_absolute(stored, document.as_deref()).map_err(|r| match r { + // Without a document, a relative path resolves but stays relative. + Refused::Invalid if document.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| cfg!(windows) || !is_windows_form(&p)) => { + NotFollowed::NoDocumentFolder + } + r => NotFollowed::Refused(r), + }) +} + +/// Whether `path`, as `craft_docpath` writes it, has a Windows drive or share root (`C:x`, `C:\x`, +/// `\\server\share\x`). Outside Windows no document folder makes such a path absolute. +fn is_windows_form(path: &Path) -> bool { + let s = path.as_os_str().as_encoded_bytes(); + s.starts_with(b"\\") || matches!(s, [l, b':', ..] if l.is_ascii_alphabetic()) } #[cfg(test)] @@ -55,5 +62,22 @@ mod tests { assert_eq!(resolve("logo.png", None), Err(NotFollowed::NoDocumentFolder)); assert_eq!(resolve(r"\\192.0.2.1\share\x.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve("//192.0.2.1/share/x.png", None), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("jar:logo.png", None), Err(NotFollowed::Refused(Refused::Url))); + // Colons in names: an alternate data stream or a URL on Windows, a plain character elsewhere. + if cfg!(windows) { + assert_eq!(resolve("img/fig:1.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); + assert_eq!(resolve("chapter1:intro.png", None), Err(NotFollowed::Refused(Refused::Url))); + } else { + assert_eq!(resolve("img/fig:1.png", Some(&doc)), Ok(std::env::temp_dir().join("art").join("img").join("fig:1.png"))); + assert_eq!(resolve("chapter1:intro.png", None), Err(NotFollowed::NoDocumentFolder)); + } + // Outside Windows a Windows path names no file: it is refused, not reported as relative. + if !cfg!(windows) { + for stored in [r"C:\art\logo.png", "C:logo.png"] { + assert_eq!(resolve(stored, Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}"); + assert_eq!(resolve(stored, None), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}"); + } + assert_eq!(resolve("../logo.png", None), Err(NotFollowed::NoDocumentFolder)); + } } } diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 45e0d7469e..416aa56b56 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -85,7 +85,7 @@ pub(crate) fn blend_long_value(m: BlendMode) -> Value { Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(n) } } -fn blend_value(m: BlendMode) -> Value { +pub(crate) fn blend_value(m: BlendMode) -> Value { let n = BLEND_NAMES.iter().find(|(b, _)| *b == m).map_or("Nrml", |(_, n)| n); Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(n) } } @@ -265,7 +265,9 @@ fn parse_one(key: &str, d: &Descriptor) -> Option { "IrGl" => Effect::InnerGlow(parse_glow(d, true)), "FrFX" => Effect::Stroke(parse_stroke(d)), "SoFi" => Effect::ColorOverlay { common: common(d, BlendMode::Normal, 1.0), color: color(d, "Clr ") }, - "GrFl" => Effect::GradientOverlay { common: common(d, BlendMode::Normal, 1.0), gradient: gradient_from(d), dither: bool_of(d, "Dthr") }, + "GrFl" => { + Effect::GradientOverlay { common: common(d, BlendMode::Normal, 1.0), gradient: gradient_from(d), dither: bool_of(d, "Dthr"), interpolation: None } + } "patternFill" => { let (name, id) = pattern_ref(d); let (angle, link, phase) = pattern_placement(d); @@ -303,7 +305,19 @@ const KINDS: [(&str, &str); 10] = [ /// Parses `lfx2` block data: `(master switch, effects)`. pub fn parse_lfx2(data: &[u8]) -> Option<(bool, Vec)> { let (vd, _) = VersionedDescriptor::parse_prefix(data.get(4..)?).ok()?; - let d = vd.descriptor; + Some(effects_from_descriptor(&vd.descriptor)) +} + +/// Keys of an effects descriptor (`lfx2`, a style library's `Lefx`) that +/// [`effects_from_descriptor`] reads, or that carry no effect of their own. +pub fn is_known_effects_key(key: &[u8]) -> bool { + KINDS.iter().any(|(single, multi)| single.as_bytes() == key || (!multi.is_empty() && multi.as_bytes() == key)) + || matches!(key, b"masterFXSwitch" | b"Scl " | b"numModifyingFX") +} + +/// The effects of an effects descriptor (the body of `lfx2`, a style library's `Lefx`): +/// `(master switch, effects)`. +pub fn effects_from_descriptor(d: &Descriptor) -> (bool, Vec) { let master = !matches!(d.get("masterFXSwitch"), Some(Value::Boolean(false))); let mut out = Vec::new(); for (single, multi) in KINDS { @@ -319,7 +333,7 @@ pub fn parse_lfx2(data: &[u8]) -> Option<(bool, Vec)> { } } _ => { - if let Some(x) = get_desc(&d, single) + if let Some(x) = get_desc(d, single) && let Some(e) = parse_one(single, x) { out.push(e); @@ -327,7 +341,7 @@ pub fn parse_lfx2(data: &[u8]) -> Option<(bool, Vec)> { } } } - Some((master, out)) + (master, out) } fn unit(u: &[u8; 4], v: f32) -> Value { @@ -428,7 +442,7 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { ("FrFX", d) } Effect::ColorOverlay { common: c, color } => ("SoFi", with_common(Descriptor::new("SoFi"), c).with("Clr ", Value::Descriptor(color_to_desc(color)))), - Effect::GradientOverlay { common: c, gradient, dither } => { + Effect::GradientOverlay { common: c, gradient, dither, .. } => { ("GrFl", gradient_keys(with_common(Descriptor::new("GrFl"), c), gradient).with("Dthr", Value::Boolean(*dither))) } Effect::PatternOverlay { common: c, name, id, scale, angle, link, phase } => ( @@ -515,6 +529,13 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { /// Serializes effects as `lfx2` block data. pub fn write_lfx2(master: bool, items: &[Effect]) -> Vec { + let mut out = 0u32.to_be_bytes().to_vec(); + out.extend(VersionedDescriptor::new(effects_descriptor(master, items)).to_bytes()); + out +} + +/// Effects as an effects descriptor (the body of `lfx2`, a style library's `Lefx`). +pub fn effects_descriptor(master: bool, items: &[Effect]) -> Descriptor { let mut d = Descriptor::new("null").with("Scl ", unit(b"#Prc", 100.0)).with("masterFXSwitch", Value::Boolean(master)); for (single, multi) in KINDS { let of_kind: Vec = items.iter().map(write_one).filter(|(k, _)| *k == single).map(|(_, d)| d).collect(); @@ -526,9 +547,7 @@ pub fn write_lfx2(master: bool, items: &[Effect]) -> Vec { _ => d = d.with(single, Value::Descriptor(of_kind.into_iter().next().unwrap_or_default())), } } - let mut out = 0u32.to_be_bytes().to_vec(); - out.extend(VersionedDescriptor::new(d).to_bytes()); - out + d } fn fixed(v: u32) -> f32 { @@ -694,7 +713,7 @@ mod tests { overprint: true, }), Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 0.5), color: Color::rgb(0.0, 0.0, 1.0) }, - Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true }, + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true, interpolation: None }, Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), name: "Bubbles".into(), diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 24cb647e44..55f602a6e9 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -92,7 +92,17 @@ fn cicp_profile(img: &Image) -> Option>> { /// milliseconds (a still GIF has one). One frame at a time becomes a document, so the caller /// can flatten and drop it. Returns the number of frames; stops at the first error `each` gives. pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { - let frames = codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?; + import_frames(name, codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?, each) +} + +/// Every frame of a WebP as a single-layer document, as [`import_gif_frames`] does for a GIF: an +/// animation's frames composited onto its canvas, with their durations in milliseconds; a still +/// WebP is one frame. +pub fn import_webp_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { + import_frames(name, codecs::decode_webp_frames(bytes, &codecs::DecodeOptions::default())?, each) +} + +fn import_frames(name: &str, frames: Vec, each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { let n = frames.len(); for f in frames { each(image_to_document(name, &f.image)?.document, f.delay_ms)?; @@ -270,9 +280,10 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result, cmyk_target let count = doc.layer_count(); warnings.push(format!("{count} layer(s) flattened; layers, masks and blend modes are not kept")); } - let cmyk = fmt.mode == ColorMode::Cmyk && cmyk_target; - // Only a CMYK target needs the document's profile; RGB-only targets keep the compositor's - // own handling (built-in coated CMYK when the embedded profile is unusable). - let space = if cmyk { + 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 + // coated CMYK when the embedded profile is unusable). use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform}; let profile = match doc.icc_profile.as_ref() { Some(bytes) => Profile::parse(bytes).map_err(|e| IoError::Unsupported(format!("invalid CMYK composite profile: {e}")))?, @@ -423,32 +435,24 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target if icc.is_none() { icc = Some(profile.to_bytes().to_vec()); } - photocraft_compose::cmyk_space(doc) + let (data, opaque) = cmyk_composite(doc, canvas, cs, n, warnings)?; + Image::from_raw(w, h, layout_for(ColorMode::Cmyk, !opaque), cs, data)? } else { - None - }; - let gray = fmt.mode == ColorMode::Grayscale; - if (fmt.mode == ColorMode::Cmyk && !cmyk) || fmt.mode == ColorMode::Lab { - // RGB-only targets use the sRGB composite directly, without reseparating CMYK. - 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 cs = csample(fmt.sample); - let colors = if cmyk { - 4 - } else 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); - let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + 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 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); + 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 }; @@ -458,15 +462,8 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), } }; - let mut converted = [0.0; 5]; for p in &band.px[range] { - if cmyk { - let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); - for value in converted.iter().take(channels) { - put(*value); - } - continue; - } else if gray { + if gray { put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); } else { put(p[0]); @@ -476,6 +473,80 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target put(p[3]); } out + }); + for part in parts { + data.extend_from_slice(&part); + } + Ok(()) + }); + 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)? + } + }; + 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 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. + 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]; + 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); + } + } + out }) }); for part in parts { @@ -483,29 +554,11 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target } Ok(()) }); - if opaque { - drop_alpha(&mut data, colors * cs.bytes(), cs.bytes()); - } - let layout = layout_for( - if cmyk { - ColorMode::Cmyk - } else if gray { - ColorMode::Grayscale - } else { - ColorMode::Rgb - }, - !opaque, - ); - Image::from_raw(w, h, layout, cs, data)? - }; - let meta = codecs::Metadata { - exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()), - xmp: doc.metadata.xmp.clone(), - text: doc.metadata.text.clone(), - dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), - ..Default::default() - }; - Ok(img.with_icc(icc).with_meta(meta)) + } + 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) @@ -616,6 +669,12 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti 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::stamp_flat_channels(doc, &mut img)); + } encode_image(&img, format, opts, warnings) } @@ -654,7 +713,7 @@ fn tga_image(doc: &Document, img: Image, bits: Option, warnings: &mut V warnings.push("transparency composited over white; a 24-bit Targa has no alpha".into()); matte_over_white(&img) } - (TgaBits::Bits32, [channel]) => tga_with_alpha_channel(doc, img, channel, warnings), + (TgaBits::Bits32, [channel]) => with_alpha_channel(doc, img, channel, "Targa", warnings), (TgaBits::Bits32, alphas) => { if !alphas.is_empty() { warnings.push("Targa holds one alpha channel; with several, none was written (as in Photoshop)".into()); @@ -667,11 +726,32 @@ fn tga_image(doc: &Document, img: Image, bits: Option, warnings: &mut V } } -/// A 32-bit Targa's image: the colours (composited over white where the layers are transparent) -/// and `channel`'s values as the alpha. -fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, warnings: &mut Vec) -> Result { +/// The image a DDS texture holds (#8563): as with a 32-bit Targa, the document's alpha channel +/// becomes the texture's alpha when it has exactly one (spot channels don't count), the colours +/// composited over white where the layers are transparent; BC1 keeps it as on/off, BC2 and BC3 +/// in full. With none, the layers' transparency is the alpha as before; with several, none of +/// them is written. Grayscale textures keep their transparency. +fn dds_image(doc: &Document, img: Image, warnings: &mut Vec) -> Result { + let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect(); + match alphas.as_slice() { + [] => Ok(img), + [channel] if img.layout().is_rgb() => with_alpha_channel(doc, img, channel, "texture", warnings), + [_] => { + warnings.push("a grayscale DDS keeps the layers' transparency; the alpha channel was not saved".into()); + Ok(img) + } + _ => { + warnings.push("a DDS texture holds one alpha channel; with several, none was written".into()); + Ok(img) + } + } +} + +/// A 32-bit Targa's or a texture's image: the colours (composited over white where the layers are +/// transparent) and `channel`'s values as the alpha. `what` names the file in the warning. +fn with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, what: &str, warnings: &mut Vec) -> Result { let img = if img.layout().has_alpha() { - warnings.push("transparency composited over white; the alpha channel is the Targa's alpha".into()); + warnings.push(format!("transparency composited over white; the alpha channel is the {what}'s alpha")); matte_over_white(&img)? } else { img diff --git a/crates/io/src/hue_sat_filter_map.rs b/crates/io/src/hue_sat_filter_map.rs index d7ce04938d..05af178c27 100644 --- a/crates/io/src/hue_sat_filter_map.rs +++ b/crates/io/src/hue_sat_filter_map.rs @@ -173,7 +173,8 @@ pub(super) fn export_item(f: &SmartFilter) -> Result { Descriptor::new("Hst2").with("H ", master.0).with("Strt", master.1).with("Lght", setting(p, "lightness", 0.0, -100.0, 100.0)?), )]; for (i, key) in RANGES.iter().enumerate() { - if let Some(o) = p.get(*key) { + // A `null` range is an untouched one, like a missing key. + if let Some(o) = p.get(*key).filter(|o| !o.is_null()) { adjs.extend(range_entry(i, o)?); } } @@ -303,6 +304,7 @@ mod tests { assert_eq!(export(json!({"hue": 12.4})), json!({"colorize": false, "hue": 12, "saturation": 0, "lightness": 0})); assert_eq!(export(json!({"hue": -400, "lightness": 101, "layer": 3})), json!({"colorize": false, "hue": -180, "saturation": 0, "lightness": 100})); assert_eq!(export(J::Null), json!({"colorize": false, "hue": 0, "saturation": 0, "lightness": 0})); + assert_eq!(export(json!({"reds": null})), json!({"colorize": false, "hue": 0, "saturation": 0, "lightness": 0})); // Colorize starts at 25 % saturation and wraps the hue into 0..360. assert_eq!(export(json!({"colorize": true, "hue": -30})), json!({"colorize": true, "hue": 330, "saturation": 25, "lightness": 0})); // The full set (`to_params`): neutral ranges at their default extent are left out, reds diff --git a/crates/io/src/kra.rs b/crates/io/src/kra.rs index 64e4facf61..af9b238971 100644 --- a/crates/io/src/kra.rs +++ b/crates/io/src/kra.rs @@ -112,7 +112,7 @@ impl Reader<'_, '_> { if file.pixel_size != space.pixel_size() { return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size))); } - let cost = file.decoded_bytes(fmt.bytes_per_pixel())?; + let cost = file.decoded_bytes(fmt.bytes_per_pixel(), dx, dy)?; self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?; let mut surface = match self.default_pixel(name, space)? { Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)), diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs index 67fd02fca5..d7330868b7 100644 --- a/crates/io/src/kra/tiles.rs +++ b/crates/io/src/kra/tiles.rs @@ -216,9 +216,27 @@ impl<'a> TileFile<'a> { Ok(TileFile { width, height, pixel_size, tiles }) } - /// Bytes the decoded tiles take at `bpp` bytes per pixel. - pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result { - self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow")) + /// Bytes the decoded tiles take at `bpp` bytes per pixel: the larger of their own pixels and + /// the whole 256×256 backing tiles they land in (a surface stores no less than a tile, so + /// sparse tiles at distinct positions retain far more than they hold). + pub(crate) fn decoded_bytes(&self, bpp: usize, dx: i32, dy: i32) -> Result { + let overflow = || invalid("tile size overflow"); + let logical = self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?; + let side = photocraft_geom::TILE_SIZE; + let (w, h) = (i32::try_from(self.width.max(1)).map_err(|_| overflow())?, i32::try_from(self.height.max(1)).map_err(|_| overflow())?); + let mut backing = std::collections::HashSet::new(); + for &(x, y, _) in &self.tiles { + // The tile lands at its offset position and covers every backing tile its box overlaps. + let (x, y) = (x.checked_add(dx).ok_or_else(overflow)?, y.checked_add(dy).ok_or_else(overflow)?); + let (x1, y1) = (x.checked_add(w - 1).ok_or_else(overflow)?, y.checked_add(h - 1).ok_or_else(overflow)?); + for c in x.div_euclid(side)..=x1.div_euclid(side) { + for r in y.div_euclid(side)..=y1.div_euclid(side) { + backing.insert((c, r)); + } + } + } + let tile_bytes = (side as usize).checked_mul(side as usize).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?; + Ok(logical.max(backing.len().checked_mul(tile_bytes).ok_or_else(overflow)?)) } /// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`). @@ -260,3 +278,31 @@ impl<'a> TileFile<'a> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn file(positions: &[(i32, i32)]) -> TileFile<'static> { + TileFile { width: 64, height: 64, pixel_size: 4, tiles: positions.iter().map(|&(x, y)| (x, y, &[][..])).collect() } + } + + #[test] + fn sparse_tiles_are_charged_for_the_backing_tiles_they_land_in() { + // Sixteen 64×64 tiles that fill one 256×256 backing tile cost what they hold. + let dense: Vec<_> = (0..4).flat_map(|y| (0..4).map(move |x| (x * 64, y * 64))).collect(); + assert_eq!(file(&dense).decoded_bytes(4, 0, 0).unwrap(), 16 * 64 * 64 * 4); + // The same number far apart each take a whole backing tile: 16 × 256 × 256 × 4. + let sparse: Vec<_> = (0..16).map(|i| (i * 10_240, 0)).collect(); + assert_eq!(file(&sparse).decoded_bytes(4, 0, 0).unwrap(), 16 * 256 * 256 * 4); + } + + #[test] + fn the_placement_offset_and_tile_extent_decide_the_backing_tiles() { + // One 64×64 tile straddling a backing-tile corner once offset touches four. + assert_eq!(file(&[(0, 0)]).decoded_bytes(4, 224, 224).unwrap(), 4 * 256 * 256 * 4); + // A tile wider than a backing tile reaches the ones between its corners. + let wide = TileFile { width: 1024, height: 1, pixel_size: 4, tiles: vec![(0, 0, &[][..])] }; + assert_eq!(wide.decoded_bytes(4, 0, 0).unwrap(), 4 * 256 * 256 * 4); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index d71a5d716b..e39b0314db 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -38,6 +38,7 @@ pub mod abr_map; mod adjust_map; pub mod affinity; pub mod annotations_map; +pub mod asl_map; pub mod blocks; pub mod camera_raw_xmp; mod channel_map; @@ -73,6 +74,7 @@ mod text_import; pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; +pub mod vector_preview; mod xcf; use photocraft_codecs::{CodecError, EncodeOptions}; @@ -82,8 +84,8 @@ use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; pub use flat::{ - DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, - tga_default_bits, + DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, import_webp_frames, + tga_alpha_channel_to_transparency, tga_default_bits, }; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; @@ -232,6 +234,9 @@ pub struct ExportOptions { pub document_path: Option, /// Reuse one cache per open document for native saves; other formats ignore it. pub pcraft_cache: Option, + /// 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, } impl Default for ExportOptions { @@ -246,6 +251,7 @@ impl Default for ExportOptions { psd_maximize_compatibility: true, document_path: None, pcraft_cache: None, + read_linked_files: true, } } } @@ -318,14 +324,57 @@ pub fn import_with_svg_group_depth_and_interrupt( max_group_depth: usize, ctl: &photocraft_raster::Interrupt, ) -> Result { - ctl.check().map_err(|_| IoError::Cancelled)?; - let r = import_stages(name, bytes, ctl, max_group_depth)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - ctl.progress(1.0); - Ok(r) + import_with_options_and_interrupt(name, bytes, &ImportOptions { max_group_depth, ..Default::default() }, ctl) } -fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result { +/// The user's settings that shape an import. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ImportOptions { + /// SVG groups nested deeper than this are rasterised. + pub max_group_depth: usize, + /// The demosaic method camera raws are developed with. + pub demosaic: raw::Demosaic, +} + +impl Default for ImportOptions { + fn default() -> Self { + ImportOptions { max_group_depth: photocraft_doc::MAX_GROUP_DEPTH, demosaic: raw::Demosaic::default() } + } +} + +/// [`import`] with the user's import settings. +pub fn import_with_options(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result { + import_with_options_and_interrupt(name, bytes, opts, &photocraft_raster::Interrupt::NONE) +} + +/// [`import_with`] with the user's import settings. +pub fn import_with_options_and_interrupt(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result { + import_for_open_with(name, bytes, opts, ctl).map(|(result, _)| result) +} + +/// The ordinary import result plus whether PSD/PSB decoding encountered damaged data. +/// Interactive callers can ask whether to continue before adding the document. This is +/// information, not a save policy: accepting it opens an ordinary, writable document. +/// Non-interactive callers keep using [`import`], which still returns readable content +/// and warnings without requiring confirmation. +/// +/// The flag is independent of the wording of `warnings`. An ordinary compatibility +/// warning does not set it. No file is decoded a second time to obtain this information. +pub fn import_for_open(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result<(ImportResult, bool), IoError> { + import_for_open_with(name, bytes, opts, &photocraft_raster::Interrupt::NONE) +} + +/// [`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> { + 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); + Ok((result, damaged)) +} + +fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, damaged: &mut bool) -> Result { // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. let (file, merged) = PsdFile::from_bytes_and_merged_recovering( bytes, @@ -337,13 +386,17 @@ fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> R psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; + let (mut document, warnings, psd_damaged) = psd_import::psd_to_document_decoded_integrity(&file, ctl, merged).ok_or(IoError::Cancelled)?; + *damaged = psd_damaged; document.name = name.to_string(); - text_import::prepare(&mut document); + let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)); + text_import::prepare(&mut document, txt2.as_ref()); Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }) } -fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result { +/// Dispatches format-specific importers and propagates a structured PSD damage +/// flag alongside the ordinary best-effort import result. +fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, opts: &ImportOptions, damaged: &mut bool) -> Result { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { let document = photocraft_format::load_from_bytes(bytes)?; @@ -352,7 +405,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m // Magic first: an Affinity file renamed .psd is still Affinity (#5258 corpus // preview_never_saves_back_to_its_native_or_renamed_source). if is_psd(bytes) { - return import_psd(name, bytes, ctl); + return import_psd(name, bytes, ctl, damaged); } if affinity::is_affinity(bytes) || affinity::has_extension(name) { return affinity::import(name, bytes); @@ -377,15 +430,15 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m return pdn::import(name, bytes, ctl); } if raw::is_raw(bytes) { - return raw::import_raw(name, bytes); + return raw::import_raw_demosaic(name, bytes, opts.demosaic); } if is_svg_input(name, bytes) { - return svg::import_svg_with_group_depth(name, bytes, max_group_depth); + return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth); } // A `.psd`/`.psb`/`.psdt` with no raster signature keeps the PSD error instead of // `codec: unrecognized image format` (#5258). Magic already returned above. if is_psd_input(name, bytes) { - return import_psd(name, bytes, ctl); + return import_psd(name, bytes, ctl, damaged); } flat::import_flat(name, bytes) } @@ -501,6 +554,7 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & force_psb: opts.force_psb || ext == "psb", maximize_compatibility: opts.psd_maximize_compatibility, document_path: opts.document_path.clone(), + read_linked_files: opts.read_linked_files, ..Default::default() }; let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); @@ -616,7 +670,44 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { #[cfg(test)] mod tests { - use super::{import, is_psd_input, is_svg_input}; + use super::{ImportOptions, import, import_for_open, is_psd_input, is_svg_input}; + use photocraft_psd::{Compression, Version, testgen}; + + /// Verifies a valid PSD remains editable after import. + #[test] + fn valid_psd_can_still_be_edited() { + let bytes = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + let imported = import("valid.psd", &bytes).unwrap(); + assert!(!imported.source_read_only); + } + + /// Verifies corrupt PSD channels produce warnings and an interactive damage flag + /// without rejecting otherwise readable headless imports. + #[test] + fn unreadable_psd_channel_keeps_best_effort_import_and_reports_damage() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let channel = file.layers_mut()[0].channels.iter_mut().find(|c| c.id == 0).unwrap(); + channel.data.clear(); + let bytes = file.to_bytes().unwrap(); + let imported = import("damaged.psd", &bytes).unwrap(); + assert!(!imported.source_read_only); + assert!(imported.warnings.iter().any(|w| w.contains("could not be decoded"))); + let (opened, damaged) = import_for_open("damaged.psd", &bytes, &ImportOptions::default()).unwrap(); + assert!(damaged); + assert!(!opened.source_read_only); + assert!(!opened.preview_only); + assert_eq!(opened.warnings, imported.warnings); + assert_eq!(opened.document.layers.len(), imported.document.layers.len()); + } + + /// Verifies healthy PSD data never requires damage confirmation. + #[test] + fn valid_psd_does_not_request_damage_confirmation() { + let bytes = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + let (result, damaged) = import_for_open("valid.psd", &bytes, &ImportOptions::default()).unwrap(); + assert!(!damaged); + assert!(!result.source_read_only); + } #[test] fn svg_extension_does_not_override_another_format_signature() { diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index cdbbfa9753..c714726b0a 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -125,10 +125,15 @@ pub fn find_linked_file(meta: &Metadata, uuid: &str) -> Option { /// 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 &meta.psd_global_blocks { + for (key, data) in blocks { if !matches!(key, b"lnk2" | b"lnk3" | b"lnkD") { continue; } diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index 6d1bcfd87f..193680d31e 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -77,9 +77,9 @@ pub(crate) fn krita_blend(id: &str) -> Option<(BlendMode, bool)> { "dodge" => (B::ColorDodge, false), "burn" => (B::ColorBurn, false), "hard_light" => (B::HardLight, false), - "soft_light" => (B::SoftLight, false), - // The W3C soft light curve, close to but not Photoshop's. - "soft_light_svg" => (B::SoftLight, true), + // Krita's own Soft Light keeps sqrt(cb) over dark backdrops; Photoshop's is the W3C curve. + "soft_light" => (B::SoftLight, true), + "soft_light_svg" => (B::SoftLight, false), "diff" => (B::Difference, false), "exclusion" => (B::Exclusion, false), "linear_burn" => (B::LinearBurn, false), @@ -124,7 +124,7 @@ pub(crate) fn krita_id(mode: BlendMode) -> Option<&'static str> { B::LinearDodge => "linear_dodge", B::LighterColor => "lighter color", B::Overlay => "overlay", - B::SoftLight => "soft_light", + B::SoftLight => "soft_light_svg", B::HardLight => "hard_light", B::VividLight => "vivid_light", B::LinearLight => "linear light", @@ -167,9 +167,6 @@ const SVG_OPS: [(&str, BlendMode); 15] = [ /// A composite-op attribute as a blend mode; unknown names fall back to Normal with a warning. fn composite_op(op: &str, layer: &str, warnings: &mut Warnings) -> BlendMode { if let Some((_, mode)) = SVG_OPS.iter().find(|(name, _)| *name == op) { - if op == "svg:soft-light" { - warnings.note("svg:soft-light (the W3C curve) opens as Photoshop's Soft Light, which differs slightly"); - } return *mode; } match op { @@ -523,6 +520,20 @@ fn png(w: u32, h: u32, px: &[[f32; 4]], sample: CSample) -> Result> { /// `px` (row-major over `rect`) cut down to the pixels that aren't fully transparent, with /// transparent pixels zeroed, so masked-out or empty margins aren't written. +/// Most bytes of straight RGBA floats one layer may take while it is written. A layer is one PNG +/// of its content's bounding box, so a few pixels far apart make a box of mostly nothing; refuse +/// that rather than allocate it. 1.5 × [`MAX_ENTRY`] of floats is at most 8 B/px × a third of that +/// raw, so the PNG stays within what [`Reader::png`] will read back. +const MAX_EXPORT_LAYER_BYTES: u64 = 3 * MAX_ENTRY as u64 / 2; + +fn admit_layer(rect: Rect) -> Result<()> { + let bytes = u64::from(rect.width()).saturating_mul(u64::from(rect.height())).saturating_mul(std::mem::size_of::<[f32; 4]>() as u64); + if bytes > MAX_EXPORT_LAYER_BYTES { + return Err(IoError::Unsupported(format!("a {}×{} layer is too large to save to OpenRaster", rect.width(), rect.height()))); + } + Ok(()) +} + fn crop(rect: Rect, mut px: Vec<[f32; 4]>) -> (Rect, Vec<[f32; 4]>) { let w = rect.width() as usize; if w == 0 { @@ -612,11 +623,12 @@ impl Writer<'_> { } /// The layer's own pixels (straight RGBA floats) and their bounds; its pixel mask applied. - fn pixels(&mut self, layer: &Layer) -> (Rect, Vec<[f32; 4]>) { + fn pixels(&mut self, layer: &Layer) -> Result<(Rect, Vec<[f32; 4]>)> { let canvas = self.doc.bounds(); let (rect, mut buf) = match &layer.content { LayerContent::Raster(s) => { let r = s.content_bounds(); + admit_layer(r)?; (r, photocraft_compose::surface_to_buffer(s, r).px) } _ => { @@ -630,6 +642,7 @@ impl Writer<'_> { solo.mask = None; 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) } }; @@ -645,7 +658,7 @@ impl Writer<'_> { p[3] *= layer.fill_opacity.clamp(0.0, 1.0); } } - crop(rect, buf) + Ok(crop(rect, buf)) } fn layers(&mut self, layers: &[Layer], depth: usize) -> Result<()> { @@ -674,7 +687,7 @@ impl Writer<'_> { if self.dry { continue; } - let (rect, px) = self.pixels(layer); + let (rect, px) = self.pixels(layer)?; let (rect, px) = if rect.is_empty() { (Rect::from_xywh(0, 0, 1, 1), vec![[0.0; 4]]) } else { (rect, px) }; let src = format!("data/layer{}.png", self.next); self.next += 1; diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index c7c253ef29..c5576ce0ef 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -327,9 +327,45 @@ fn reads_other_applications_stacks() { assert_eq!(get("Strange").blend, BlendMode::Normal); assert!(get("Lost").surface().unwrap().content_bounds().is_empty()); let all = r.warnings.join("\n"); - for needle in ["svg:dst-in", "", "missing from the archive", "krita:hard mix", "svg:soft-light"] { + for needle in ["svg:dst-in", "", "missing from the archive", "krita:hard mix"] { assert!(all.contains(needle), "{needle} not in {all}"); } + // svg:soft-light is the W3C curve, which is Photoshop's Soft Light (#8804): no warning. + assert!(!all.contains("soft-light"), "{all}"); +} + +#[test] +fn krita_soft_light_ids_map_to_photoshop_soft_light() { + // Krita's own soft_light keeps sqrt(cb) over dark backdrops, so it is the approximation; + // soft_light_svg is the W3C curve Photoshop uses (#8804). + assert_eq!(super::krita_blend("soft_light"), Some((BlendMode::SoftLight, true))); + assert_eq!(super::krita_blend("soft_light_svg"), Some((BlendMode::SoftLight, false))); + assert_eq!(super::krita_id(BlendMode::SoftLight), Some("soft_light_svg")); + let xml = r#" + + + "#; + let r = crate::import("soft.ora", &archive(xml, &[("a.png", solid_png(2, 2, [40, 40, 40, 255]))])).unwrap(); + assert!(r.document.layers.iter().all(|l| l.blend == BlendMode::SoftLight)); + let all = r.warnings.join("\n"); + assert!(all.contains("krita:soft_light blend mode is approximated"), "{all}"); + assert!(!all.contains("soft_light_svg"), "{all}"); +} + +#[test] +fn soft_light_exports_as_svg_soft_light() { + // The W3C id, not krita:soft_light_svg (which import also accepts): other applications read it. + let mut d = doc(SampleType::U8); + let mut soft = block("Soft", PixelFormat::RGBA8, 0, 0, 10, 10, 5); + soft.blend = BlendMode::SoftLight; + d.layers.push(soft); + let out = export(&d).unwrap(); + let xml = String::from_utf8(ZipReader::new(&out.bytes).unwrap().read_by_name("stack.xml", 1 << 20).unwrap()).unwrap(); + assert!(xml.contains("name=\"Soft\"") && xml.contains("composite-op=\"svg:soft-light\""), "{xml}"); + assert!(!xml.contains("soft_light"), "{xml}"); + let (back, exported, imported) = round_trip(&d); + assert!(back.layers.iter().any(|l| l.name == "Soft" && l.blend == BlendMode::SoftLight)); + assert!(!exported.iter().chain(&imported).any(|w| w.to_lowercase().contains("soft")), "{exported:?} {imported:?}"); } #[test] diff --git a/crates/io/src/palette_map.rs b/crates/io/src/palette_map.rs index 9d1d677ad3..45b8168f1c 100644 --- a/crates/io/src/palette_map.rs +++ b/crates/io/src/palette_map.rs @@ -146,3 +146,144 @@ fn bits(doc: &Document) -> Vec { }); out } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{Color, SampleType}; + use photocraft_doc::Size; + + fn document(width: u32, height: u32, depth: SampleType, mode: ColorMode, pixels: &[f32]) -> Document { + let mut doc = Document::with_background("palette test", Size::new(width, height), ColorMode::Rgb, depth, Color::rgba(0.0, 0.0, 0.0, 1.0)); + let bounds = doc.bounds(); + doc.layers[0].surface_mut().unwrap().write_region(bounds, pixels); + doc.mode = mode; + doc + } + + fn round_trip(doc: &Document) -> (PsdFile, Document) { + let (file, _) = document_to_psd(doc, false).unwrap(); + let bytes = file.to_bytes().unwrap(); + let file = PsdFile::from_bytes(&bytes).unwrap(); + let back = crate::import("test.psd", &bytes).unwrap().document; + (file, back) + } + + #[test] + fn full_palette_endpoints_round_trip_with_either_transparent_endpoint() { + let colors: Vec<_> = (0..=255).map(|v| [v, 255 - v, v / 2]).collect(); + for transparent in [0, 255] { + let opaque = 255 - transparent; + let rgb = colors[usize::from(opaque)].map(|v| f32::from(v) / 255.0); + let mut doc = document(2, 1, SampleType::U8, ColorMode::Indexed, &[rgb[0], rgb[1], rgb[2], 1.0, 0.0, 0.0, 0.0, 0.0]); + doc.color_table = Some(ColorTable { colors: colors.clone(), transparent: Some(transparent) }); + let (file, back) = round_trip(&doc); + assert_eq!((file.header.color_mode, file.header.depth, file.header.channels), (PsdMode::Indexed, 8, 1)); + assert_eq!(file.decode_merged().unwrap(), [opaque, transparent]); + assert_eq!(file.resource(COLOR_TABLE_COUNT).unwrap().data, [1, 0]); + assert_eq!(file.color_mode_data.len(), 768); + for (i, c) in colors.iter().enumerate() { + assert_eq!([file.color_mode_data[i], file.color_mode_data[256 + i], file.color_mode_data[512 + i]], *c); + } + assert_eq!(back.color_table, doc.color_table); + let surface = back.layers[0].surface().unwrap(); + assert_eq!(surface.pixel(0, 0), vec![rgb[0], rgb[1], rgb[2], 1.0]); + assert_eq!(surface.pixel(1, 0)[3], 0.0); + } + } + + #[test] + fn indexed_alpha_threshold_uses_stored_samples_at_all_depths() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = document(3, 1, depth, ColorMode::Indexed, &[0.0, 0.0, 0.0, 0.49, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.51]); + doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: Some(1) }); + let (file, back) = round_trip(&doc); + assert_eq!(file.decode_merged().unwrap(), [1, 0, 0], "{depth:?}"); + let surface = back.layers[0].surface().unwrap(); + assert_eq!((0..3).map(|x| surface.pixel(x, 0)[3]).collect::>(), [0.0, 1.0, 1.0]); + } + } + + #[test] + fn small_palette_zero_pads_unused_entries_and_rejects_invalid_lengths() { + let mut doc = document(1, 1, SampleType::U8, ColorMode::Indexed, &[1.0, 0.0, 0.0, 1.0]); + doc.color_table = Some(ColorTable { colors: vec![[255, 0, 0]], transparent: None }); + let (file, back) = round_trip(&doc); + assert_eq!(file.color_mode_data[0], 255); + assert!(file.color_mode_data[1..].iter().all(|&v| v == 0)); + assert_eq!(back.color_table, doc.color_table); + for count in [0, 257] { + doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0]; count], transparent: None }); + assert!(document_to_psd(&doc, false).is_none(), "{count}"); + } + doc.color_table = None; + assert!(document_to_psd(&doc, false).is_none()); + } + + #[test] + fn indexed_import_bounds_malformed_count_and_transparency_resources() { + let mut doc = document(1, 1, SampleType::U8, ColorMode::Indexed, &[0.0, 0.0, 0.0, 1.0]); + doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: None }); + let (original, _) = document_to_psd(&doc, false).unwrap(); + for (count, expected) in [(vec![0, 0], 1), (vec![1, 1], 256), (vec![255, 255], 256), (vec![0], 256)] { + let mut file = original.clone(); + file.resources.retain(|r| r.id != COLOR_TABLE_COUNT); + file.resources.push(ImageResource::new(COLOR_TABLE_COUNT, count)); + file.resources.push(ImageResource::new(TRANSPARENCY_INDEX, vec![255, 255])); + let bytes = file.to_bytes().unwrap(); + let back = crate::import("malformed.psd", &bytes).unwrap().document; + let table = back.color_table.unwrap(); + assert_eq!(table.colors.len(), expected); + assert_eq!(table.transparent, None); + assert_eq!(back.layers[0].surface().unwrap().pixel(0, 0)[3], 1.0); + } + } + + #[test] + fn bitmap_rows_are_msb_first_and_independently_byte_padded() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for width in [1u32, 7, 8, 9, 15, 17] { + // Opposite rows expose padding carried into the next row or reversed bit order. + let pixels: Vec<_> = (0..2) + .flat_map(|y| { + (0..width).flat_map(move |x| { + let v = if (x + y) % 2 == 0 { 0.0 } else { 1.0 }; + [v, v, v, 1.0] + }) + }) + .collect(); + let doc = document(width, 2, depth, ColorMode::Bitmap, &pixels); + let (file, back) = round_trip(&doc); + assert_eq!((file.header.color_mode, file.header.depth, file.header.channels), (PsdMode::Bitmap, 1, 1)); + let mut expected = Vec::new(); + for y in 0..2 { + for byte in 0..width.div_ceil(8) { + let mut packed = 0; + for bit in 0..8 { + let x = byte * 8 + bit; + if x < width && (x + y) % 2 == 0 { + packed |= 0x80 >> bit; + } + } + expected.push(packed); + } + } + assert_eq!(file.decode_merged().unwrap(), expected, "{depth:?}, width={width}"); + assert!(file.color_mode_data.is_empty()); + assert_eq!(back.mode, ColorMode::Bitmap); + let gray_alpha: Vec<_> = pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[3]]).collect(); + assert_eq!(back.layers[0].surface().unwrap().read_region(back.bounds()), gray_alpha); + } + } + } + + #[test] + fn bitmap_alpha_is_flattened_over_white_before_thresholding() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = document(3, 1, depth, ColorMode::Bitmap, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.49, 0.0, 0.0, 0.0, 0.51]); + let (file, back) = round_trip(&doc); + assert_eq!(file.decode_merged().unwrap(), [0x20], "{depth:?}"); + assert_eq!(back.layers[0].surface().unwrap().read_region(back.bounds()), [1.0, 1.0, 1.0, 1.0, 0.0, 1.0]); + } + } +} diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index bd84d9aeb1..eeda1943cc 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Pattern}; use photocraft_geom::Rect; -use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_key, mode_channels, parse_pattern_block_with, write_pattern_block}; +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}; @@ -124,12 +124,13 @@ pub fn from_global_blocks(doc: &Document) -> Vec { .collect() } -/// Global blocks to write: the raw ones while they still match [`Document::patterns`], else the -/// raw pattern blocks replaced by one written from the document (at the first one's position). +/// Global blocks to write: the raw ones while they still match [`Document::patterns`] (see +/// [`repair_legacy_blocks`]), else the raw pattern blocks replaced by one written from the +/// document (at the first one's position). pub fn export_global_blocks(doc: &Document) -> Vec { let raw = &doc.metadata.psd_global_blocks; if from_global_blocks(doc) == doc.patterns { - return raw.clone(); + return repair_legacy_blocks(raw); } let key = block_key(psd_depth(doc.depth)); let fresh = write_pattern_block(&doc.patterns.iter().map(to_psd).collect::>()).unwrap_or_default(); @@ -151,6 +152,31 @@ pub fn export_global_blocks(doc: &Document) -> Vec Vec { + if !raw.iter().any(|(_, k, d)| PATTERN_KEYS.contains(k) && block_has_user_mask(d)) { + return raw.to_vec(); + } + let mut budget = PatternBudget::default(); + raw.iter() + .map(|b @ (sig, key, data)| { + if !PATTERN_KEYS.contains(key) { + return b.clone(); + } + match parse_pattern_block_with(data, &mut budget) { + Ok(ps) if block_has_user_mask(data) => match write_pattern_block(&ps) { + Ok(fresh) => (*sig, *key, Arc::new(fresh)), + Err(_) => b.clone(), + }, + _ => b.clone(), + } + }) + .collect() +} + /// Reads a `.pat` pattern file. pub fn read_pat(bytes: &[u8]) -> Result, String> { let ps = photocraft_psd::patterns::parse_pat_file(bytes).map_err(|e| format!("not a readable .pat file: {e}"))?; @@ -217,4 +243,38 @@ mod tests { assert_eq!(from_global_blocks(&d2), d.patterns); assert_eq!(read_pat(&write_pat(&d.patterns).unwrap()).unwrap(), d.patterns); } + + #[test] + fn transparent_pattern_blocks_from_older_builds_are_rewritten_on_save() { + // #8054: Define Pattern over transparency, then a Pattern Fill layer; Photoshop refused + // the PSD because the pattern's transparency sat in the user mask array. + let mut d = Document::new("x", photocraft_geom::Size { width: 4, height: 4 }, ColorMode::Rgb, SampleType::U16); + d.patterns.push(pattern(ColorMode::Rgb, SampleType::U16, true)); + let out = export_global_blocks(&d); + assert_eq!(out.len(), 1); + assert!(!block_has_user_mask(&out[0].2)); + // Move the transparency into the user mask array, as older builds wrote it: a 16-bit + // pattern's array is raw, so it is the last 8 + 23 + 5 × 3 × 2 bytes of the pattern. + let mut legacy = out[0].2.to_vec(); + let end = 4 + u32::from_be_bytes([legacy[0], legacy[1], legacy[2], legacy[3]]) as usize; + legacy[end - (8 + 23 + 30) - 4..end].rotate_left(4); + assert!(block_has_user_mask(&legacy)); + // A pattern block that doesn't decode (pattern version 2) sits beside it. + let unreadable = (*b"8BIM", *b"Patt", Arc::new(vec![0, 0, 0, 8, 0, 0, 0, 2, 0, 0, 0, 0])); + let other = (*b"8BIM", *b"Txt2", Arc::new(vec![1])); + d.metadata.psd_global_blocks = vec![other.clone(), (*b"8BIM", *b"Pat2", Arc::new(legacy)), unreadable.clone()]; + assert_eq!(from_global_blocks(&d), d.patterns, "older files still open"); + // The patterns are unchanged: only the legacy block is rewritten, in place; the other + // blocks are kept verbatim. + let out = export_global_blocks(&d); + assert_eq!(out.len(), 3); + assert_eq!((&out[0], &out[2]), (&other, &unreadable)); + assert_eq!(out[1].1, *b"Pat2"); + assert!(!block_has_user_mask(&out[1].2)); + let mut back = d.clone(); + back.metadata.psd_global_blocks = out; + assert_eq!(from_global_blocks(&back), d.patterns); + // Saving again keeps every block as it is. + assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks); + } } diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 0251f83755..15e648df1a 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -21,6 +21,14 @@ pub fn sample_for_depth(depth: u16) -> SampleType { } } +/// Complements every native-endian sample of one plane (`max − v`, `1 − v` for float). A +/// trailing partial sample is left unchanged. +pub(crate) fn invert_plane(bytes: &mut [u8], s: SampleType) { + for c in bytes.chunks_exact_mut(s.bytes().max(1)) { + invert_sample(c, s); + } +} + fn invert_sample(bytes: &mut [u8], s: SampleType) { match s { SampleType::U8 => bytes[0] = 255 - bytes[0], @@ -47,7 +55,7 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: Sampl return out; } let bs = bands(n); - let step = bs.first().map_or(n, |b| b.len()) * row; + 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(); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 0fc1a94e4f..eacab4f203 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -21,6 +21,10 @@ use crate::psd_import::REGENERATED; const RASTER_STALE_KEYS: [&[u8; 4]; 8] = [b"SoCo", b"GdFl", b"PtFl", b"SoLd", b"PlLd", b"SoLE", b"TySh", b"vscg"]; const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; +/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and +/// the channel planes made from them (or the band parts and the joined buffer) are alive together, +/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`]. +const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 }; const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024; const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024; @@ -50,6 +54,10 @@ pub struct PsdExportOptions { /// The path of the document's own file: linked smart objects are resolved against it /// ([`crate::doc_path::resolve`]) before they are read and embedded. pub document_path: Option, + /// Read and embed the files linked smart objects name. On by default; automation turns it + /// 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, } impl Default for PsdExportOptions { @@ -61,6 +69,7 @@ impl Default for PsdExportOptions { merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None, + read_linked_files: true, } } } @@ -71,6 +80,8 @@ struct Ex { dpi: f32, /// The document's own file, which linked smart objects are resolved against. document: Option, + /// Whether linked files may be read and embedded ([`PsdExportOptions::read_linked_files`]). + read_linked_files: bool, /// Character/paragraph styles written into every type layer's engine data. text_styles: photocraft_doc::TextStyles, mask_fmt: PixelFormat, @@ -143,6 +154,18 @@ fn contents_uuid(contents_id: SmartContentsId, bytes: &[u8]) -> String { 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. +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)] struct SmartOut { @@ -158,8 +181,12 @@ struct SmartOut { prune_ok: bool, /// Files to embed. add: Vec, - /// Sources converted so far, by contents identity and bytes (`Err` = can't be embedded). - converted: std::collections::HashMap<(SmartContentsId, [u8; 32]), Result>, + /// Sources converted so far, by contents identity, bytes and whether they were read from a + /// link (`Err` = can't be embedded). + converted: std::collections::HashMap<(SmartContentsId, [u8; 32], bool), Result>, + /// Only immutable Embedded Arc owners are memoized; pinning them prevents address reuse. + /// Ephemeral linked-file reads always hash their current bytes independently. + embedded_hashes: std::collections::HashMap, /// Each placed layer has its own id, even when it duplicates an imported layer. placed_used: std::collections::HashSet, /// Original instances reserve their imported placement regardless of layer order. @@ -183,6 +210,9 @@ 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`). unfiltered: std::collections::HashMap, + /// Native vector previews validated once per source and preview, shared by both their + /// `pcVP` block and the source document of the filter caches. + vector_previews: crate::vector_preview::ExportCache, } /// Whether a smart object's filter mask shows: enabled over at least one visible, enabled filter. @@ -234,6 +264,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). + 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. @@ -375,6 +419,13 @@ impl Ex { if r.is_empty() { 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. + 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); + } // 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. let row_len = r.width() as usize; @@ -573,6 +624,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 @@ -630,8 +684,8 @@ impl Ex { photocraft_doc::Adjustment::Unsupported { psd_key, .. } => Some(psd_key.as_bytes()), _ => None, }; - let keep = - keep || matches!(a, photocraft_doc::Adjustment::Unsupported { raw: r, .. } if raw.iter().any(|(k, d)| own == Some(&k[..]) && d == r)); + let keep = keep + || matches!(a, photocraft_doc::Adjustment::Unsupported { raw: r, .. } if raw.iter().any(|(k, d)| own == Some(&k[..]) && d[..] == r[..])); if !keep { raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd" && own != Some(&k[..])); let w = adjust_map::write(a); @@ -658,6 +712,10 @@ impl Ex { crate::vector_map::shape_blocks(sh, &mut raw, w, h, self.dpi); } LayerContent::Text(t) => { + if matches!(t.shape, photocraft_doc::text::TextShape::Area { .. }) { + self.warnings + .push(format!("layer \"{}\": area type is saved as paragraph text over its path's bounds (PSD area type is not written yet)", l.name)); + } // Text layers without PSD data (created here) get a generated TySh. let generated = t.psd_raw.is_none().then(|| std::sync::Arc::new(photocraft_text::psd::build_tysh(t, self.dpi, None))); let src = t.psd_raw.as_ref().or(generated.as_ref()); @@ -678,6 +736,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 @@ -731,9 +792,24 @@ impl Ex { let id = self.layer_ids.get(&l.id).copied().unwrap_or(0); crate::comps_map::set_cmls(&mut raw, crate::comps_map::write_cmls(comps, last.as_ref(), l, id)); } + self.layer_xmp_block(l, &mut raw); regenerated.into_iter().chain(raw).map(|(k, d)| TaggedBlock::new(k, d)).collect() } + /// Writes the layer's XMP packet into its `shmd` block (created when it has none). Import + /// takes the packet out of `shmd`, so a layer without one keeps the block as it was. + fn layer_xmp_block(&mut self, l: &Layer, raw: &mut Vec<([u8; 4], Vec)>) { + let Some(xmp) = l.xmp.as_deref() else { return }; + let pos = raw.iter().position(|(k, _)| k == b"shmd"); + match photocraft_psd::metadata::put_layer_xmp(pos.map(|i| raw[i].1.as_slice()), xmp) { + Ok(data) => match pos { + Some(i) => raw[i].1 = data, + None => raw.push((*b"shmd", data)), + }, + Err(e) => self.warnings.push(format!("layer \"{}\": its XMP metadata was not saved (its metadata block can't be read: {e})", l.name)), + } + } + /// Keeps, regenerates or removes the `vmsk`/`vsms` block of a non-shape layer. fn vector_mask_block(&mut self, l: &Layer, raw: &mut Vec<([u8; 4], Vec)>) { let (w, h) = (self.canvas.width(), self.canvas.height()); @@ -922,7 +998,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) => { @@ -962,17 +1039,30 @@ impl Ex { SmartSource::Linked { path } => { #[cfg(not(target_arch = "wasm32"))] { + if !self.read_linked_files { + 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}"))?; 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)")); - self.embed(sm.contents_id, &name, &bytes) + Ok(source) } #[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), + 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 @@ -1016,17 +1106,25 @@ impl Ex { /// Adds `bytes` as an embedded file (converted to PSB when it is a `.pcraft` bundle), once /// per contents identity and bytes. Equal bytes in independent objects stay independent. - fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8]) -> Result { - let key = (contents_id, *blake3::hash(bytes).as_bytes()); + /// `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 { + 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(); } - let r = self.convert_source(contents_id, file_name, bytes); + let r = self.convert_source(contents_id, file_name, bytes, linked); self.smart.converted.insert(key, r.clone()); r } - fn convert_source(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8]) -> Result { + fn convert_source(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result { let (name, data, size, dpi, nested) = if photocraft_format::is_pcraft(bytes) { if self.smart.depth >= MAX_NESTING { return Err(format!("smart objects nest more than {MAX_NESTING} deep")); @@ -1034,7 +1132,7 @@ impl Ex { let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; let (file, warnings) = document_to_psd_nested( &doc, - &PsdExportOptions { force_psb: true, document_path: self.document.clone(), ..Default::default() }, + &PsdExportOptions { force_psb: true, document_path: self.document.clone(), read_linked_files: self.read_linked_files, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE, ); @@ -1044,9 +1142,13 @@ impl Ex { let size = (f64::from(doc.size.width), f64::from(doc.size.height)); (format!("{stem}.psb"), data, size, f64::from(doc.resolution_dpi), Some(doc)) } else { - // Formats we can't decode (vector PDF/AI…) are still embedded; their size comes from the layer. - let (size, dpi) = - crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); + // Embedded formats we can't decode (vector PDF/AI…) are kept; their size comes from the + // layer. A linked file we can't decode is not embedded. + let (size, dpi) = match crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE) { + Ok(geometry) => geometry, + Err(e) if linked => return Err(format!("the linked file {file_name} is not an image PhotoCraft can open: {e}")), + Err(_) => ((0.0, 0.0), f64::from(self.dpi)), + }; (file_name.to_string(), bytes.to_vec(), size, dpi, None) }; let uuid = contents_uuid(contents_id, &data); @@ -1222,9 +1324,12 @@ pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec { let mut v = Vec::new(); for n in names { let b = legacy_name(n); - v.push(b.len() as u8); - v.extend_from_slice(&b); + let n = b.len().min(255); + v.push(n as u8); + v.extend_from_slice(&b[..n]); } + // No per-string pad: Photoshop's resource 1006 for "Spot Color 1" is 13 bytes + // (length byte + 12 characters). The 8BIM container pads an odd resource separately. v } @@ -1323,6 +1428,9 @@ fn cmyk_of(fmt: &PixelFormat) -> bool { /// `ctl` gets the fraction of rows done after each band; once it is cancelled the rest of the /// image is left blank (the caller discards it). fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl: &Interrupt) -> (Vec, bool, bool) { + if cmyk && let Some(planes) = merged_ink_planes(doc, fmt.sample, matte, ctl) { + return planes; + } let sample = fmt.sample; let cc = fmt.mode.color_channels(); let bps = sample.bytes(); @@ -1380,6 +1488,57 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl (planes, has_alpha, translucent) } +/// [`merged_planes`] of a CMYK document whose layers are mixed on their ink values +/// (`photocraft_compose::cmyk`), so opaque pixels keep their exact inks; `None` for documents +/// that composite in RGB. Reports progress and stops on cancel like [`merged_planes`]. +fn merged_ink_planes(doc: &Document, sample: SampleType, matte: bool, ctl: &Interrupt) -> Option<(Vec, bool, bool)> { + let bps = sample.bytes(); + let n = doc.size.area() as usize; + let plane = n * bps; + let mut planes = vec![0u8; plane * 5]; + let (mut has_alpha, mut translucent) = (false, false); + let canvas = doc.bounds(); + let w = canvas.width() as usize; + let rows = canvas.height().max(1) as f32; + // A cancelled export leaves the rest blank (the caller discards it), so the result stands either way. + let _ = photocraft_compose::cmyk::render_cmyk_bands(doc, canvas, 0, |band| -> Result<(), ()> { + if ctl.cancelled() { + return Err(()); + } + // Retain alpha whenever it differs from opaque at the stored precision. + has_alpha |= band.px.iter().any(|p| match sample { + SampleType::U8 => q255(p[4]) < 255, + SampleType::U16 => (p[4].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX, + SampleType::F32 => p[4] != 1.0, + }); + translucent |= band.px.iter().any(|p| p[4] < 1.0); + let start = (band.rect.y0 - canvas.y0) as usize * w; + let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { + let mut out: Vec> = (0..5).map(|_| Vec::with_capacity(range.len() * bps)).collect(); + for p in band.px.get(range.clone()).unwrap_or(&[]) { + for (c, o) in out.iter_mut().enumerate() { + // Inks matted against white paper (no ink) like Photoshop (see + // `pixels::matte`), and stored inverted as PSD's CMYK is. + let v = if c < 4 { 1.0 - if matte { crate::pixels::matte(p[c], p[4], 0.0) } else { p[c] } } else { p[4] }; + encode_be(v, sample, o); + } + } + (range.start, out) + }); + for (at, part) in parts { + for (c, src) in part.iter().enumerate() { + let o = c * plane + (start + at) * bps; + if let Some(dst) = planes.get_mut(o..o + src.len()) { + dst.copy_from_slice(src); + } + } + } + ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows); + Ok(()) + })?; + Some((planes, has_alpha, translucent)) +} + /// The merged image of a file saved without Maximize Compatibility: Photoshop's white placeholder /// in the document's colour channels, with no alpha. Encoded like [`merged_planes`], so CMYK white /// is no ink and Lab white is L 100 with neutral a*/b*. @@ -1519,7 +1678,7 @@ pub(crate) fn estimate_psd_size(doc: &Document) -> Option { structural_bytes = structural_bytes .checked_add(u64::try_from(doc.icc_profile.as_ref().map_or(0, |profile| profile.len())).ok()?)? - .checked_add(u64::try_from(doc.metadata.xmp.as_ref().map_or(0, String::len)).ok()?)? + .checked_add(u64::try_from(doc.metadata.xmp.as_deref().map_or(0, str::len)).ok()?)? .checked_add(u64::try_from(doc.metadata.exif.as_ref().map_or(0, |data| data.len())).ok()?)?; for (_, name, data) in &doc.metadata.psd_resources { structural_bytes = structural_bytes.checked_add(u64::try_from(name.len()).ok()?)?.checked_add(u64::try_from(data.len()).ok()?)?; @@ -1570,7 +1729,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c .duotone .as_ref() .filter(|d| !d.inks.is_empty() && doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample == SampleType::U8) - .map(|d| d.psd_raw.clone().unwrap_or_else(|| crate::duotone_map::write(d))); + .map(|d| d.psd_raw.as_deref().map_or_else(|| crate::duotone_map::write(d), <[u8]>::to_vec)); let sample = fmt.sample; let cc = fmt.mode.color_channels(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; @@ -1580,6 +1739,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c fmt, dpi: doc.resolution_dpi, document: opts.document_path.clone(), + read_linked_files: opts.read_linked_files, text_styles: doc.text_styles.clone(), text_index: Default::default(), mask_fmt: PixelFormat::new(ColorMode::Grayscale, sample, false), @@ -1721,7 +1881,11 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c }; for a in &extra { let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; - let bytes = s.to_interleaved(canvas); + let mut bytes = s.to_interleaved(canvas); + // Spot ink as Photoshop stores it (0 = solid); an alpha channel stays straight. + if a.spot.is_some() { + crate::channel_map::photoshop_spot_plane(&mut bytes, sample); + } planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); } let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; @@ -1753,6 +1917,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(ImageResource::new(1006, pascal_names_resource(&names))); resources.push(ImageResource::new(1045, unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha))); + // Photoshop writes 1053 in every file with an extra channel. Its absence is how a file from + // an earlier PhotoCraft is told apart (`psd_import::earlier_photocraft_spot_planes`). + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(has_alpha, extra.len()))); } if let Some(id) = quick_mask_id { let mut data = id.to_be_bytes().to_vec(); @@ -1804,7 +1971,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { continue; } - global_blocks.push(global_block(block_signature(sig, key, version), key, &data)); + let block = global_block(block_signature(sig, key, version), key, &data); + // A kept merged-transparency marker describes the opened file's composite. Over an opaque + // one it would make the first alpha channel read back as the transparency, and vanish. + if !has_alpha && block.is_merged_transparency_marker() { + continue; + } + global_blocks.push(block); } if let Some(txt2) = txt2 { let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone()); @@ -1829,6 +2002,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } + // 1053 is written afresh with the extra channels. A kept copy (from a Multichannel file, or + // a document saved by an earlier version) would be a second one, or list channels that + // are gone. + if *id == crate::channel_map::ALPHA_IDENTIFIERS { + continue; + } let mut r = ImageResource::new(*id, data.to_vec()); r.name = legacy_name(name); resources.push(r); @@ -1845,10 +2024,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); - // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer - // block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right - // after the last kept marker, else first. - let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1); + // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`; `MTrn` in a TIFF) + // before the layer block, and won't open a file whose `Lr32` comes first (#5068): the layer + // block goes right after the last kept marker, else first. + let index = global_blocks.iter().rposition(TaggedBlock::is_merged_transparency_marker).map_or(0, |i| i + 1); let placement = match (has_layers, sample) { (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None }, (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None }, @@ -1956,6 +2135,100 @@ mod tests { assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); } + /// A layered document with a spot and an alpha channel. Each channel holds 100 %, 25 %, 75 % + /// and 0 % in four columns, the layout of the Photoshop spot oracles. + fn spot_and_alpha_document() -> Document { + let mut doc = document(4, 1); + doc.layers.push(Layer::group("Group", vec![Layer::raster("Layer 1", doc.pixel_format())])); + let channel = |name: &str| { + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_interleaved(doc.bounds(), &[255, 64, 191, 0]); + photocraft_doc::AlphaChannel::new(name, s) + }; + let mut spot = channel("Spot Color 1"); + spot.spot = Some((photocraft_doc::Color::rgb(0.0, 0.6, 1.0), 0.0)); + let alpha = channel("Alpha 1"); + doc.channels.extend([spot, alpha]); + doc + } + + /// The extra planes after the colour channels (and the merged transparency) of `file`. + fn extra_planes(file: &PsdFile, count: usize) -> Vec { + let planes = file.decode_merged().unwrap(); + let plane = (file.header.width * file.header.height) as usize * usize::from(file.header.depth / 8); + planes[planes.len() - count * plane..].to_vec() + } + + /// Resource 1053's identifiers. + fn alpha_ids(file: &PsdFile) -> Option> { + file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map(|r| r.data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()) + } + + /// A PSD stores spot ink as Photoshop's own PSDs do (0 = solid ink: the 100, 25, 75 and 0 % + /// columns are 0, 191, 64, 255) and an alpha channel straight (255 = selected). The global + /// layer mask stays empty and flag bit 3 stays on the layers only, not on the group's closing + /// divider: Photoshop opens those as they are, and 509 of the corpus's 721 Photoshop PSDs have + /// an empty mask too. Opening the file gives the same ink back. + #[test] + fn psd_stores_spot_ink_as_photoshop_does() { + let doc = spot_and_alpha_document(); + let file = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + assert_eq!(extra_planes(&file, 2), [0, 191, 64, 255, 255, 64, 191, 0], "spot, then alpha"); + assert_eq!(file.global_layer_mask.as_ref().map(|m| m.data.len()), Some(0)); + let flags: Vec = file.layers().iter().map(|l| l.flags.0 & LayerFlags::BIT4_USEFUL).collect(); + assert_eq!(flags, [0, LayerFlags::BIT4_USEFUL, LayerFlags::BIT4_USEFUL], "closing divider, layer, group"); + let back = crate::import("spot.psd", &file.to_bytes().unwrap()).unwrap().document; + assert!(back.channels[0].spot.is_some() && back.channels[1].spot.is_none()); + for c in &back.channels { + assert_eq!(c.surface.to_interleaved(back.bounds()), [255, 64, 191, 0], "{}", c.name); + } + } + + /// Resource 1053 numbers the extra channels as Photoshop 27.11 does in a new document: 3, 4, 5 + /// in every colour mode, after 0 for the merged transparency when there is one (its + /// transparent PSD lists `0, 3, 4, 5`). A Quick Mask is an extra channel too. Without extra + /// channels there is none. + #[test] + fn psd_alpha_identifiers_number_the_channels_as_photoshop_does() { + use photocraft_color::Color; + let size = photocraft_geom::Size::new(4, 1); + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] { + let mut doc = Document::with_background("bg", size, mode, SampleType::U8, Color::rgb(1.0, 1.0, 1.0)); + assert_eq!(alpha_ids(&document_to_psd(&doc)), None, "{mode:?} without channels"); + doc.channels = spot_and_alpha_document().channels; + let file = document_to_psd(&doc); + assert!(!file.merged_has_alpha(), "{mode:?}"); + assert_eq!(alpha_ids(&file), Some(vec![3, 4]), "{mode:?}"); + doc.quick_mask = Some(photocraft_doc::AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8))); + assert_eq!(alpha_ids(&document_to_psd(&doc)), Some(vec![3, 4, 5]), "{mode:?} with a Quick Mask"); + } + // The empty layer leaves the composite transparent. + let file = document_to_psd(&spot_and_alpha_document()); + assert!(file.merged_has_alpha()); + assert_eq!(alpha_ids(&file), Some(vec![0, 3, 4])); + } + + /// A save writes one fresh 1053 for the channels the document has. A Photoshop file's own is + /// not kept on open (it would be new metadata after every round trip), and one a document + /// carries anyway (kept by an earlier version) is never written: next to the fresh one it + /// would be a second, and without channels it would list channels that are gone. The + /// identifiers here, 3 and 2, are those of psd-tools' `cmyk-alpha-spot.psd` (Photoshop 24.7). + #[test] + fn a_kept_alpha_identifiers_resource_is_not_written() { + let kept = || (crate::channel_map::ALPHA_IDENTIFIERS, String::new(), std::sync::Arc::new([0, 0, 0, 3, 0, 0, 0, 2].to_vec())); + let mut file = document_to_psd(&spot_and_alpha_document()); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + file.resources.push(ImageResource::new(kept().0, kept().2.to_vec())); + let mut opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; + assert!(!opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS)); + opened.metadata.psd_resources.push(kept()); + let again = PsdFile::from_bytes(&crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).count(), 1); + assert_eq!(alpha_ids(&again), Some(vec![0, 3, 4])); + opened.channels.clear(); + assert_eq!(alpha_ids(&document_to_psd(&opened)), None, "no channels left"); + } + #[test] fn group_divider_id_wraps_without_colliding_with_imported_layer_ids() { let mut doc = document(8, 8); @@ -1982,7 +2255,12 @@ mod tests { /// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker. #[test] fn layer_block_follows_a_kept_merged_transparency_marker() { - for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] { + for (depth, key, marker) in [ + (SampleType::F32, *b"Lr32", *b"Mt32"), + (SampleType::U16, *b"Lr16", *b"Mt16"), + (SampleType::F32, *b"Lr32", *b"Mtrn"), + (SampleType::F32, *b"Lr32", *b"MTrn"), + ] { let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); let mut s = Surface::new(doc.pixel_format()); s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]); @@ -2002,6 +2280,28 @@ mod tests { assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. })); } + /// 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. + #[test] + fn an_opaque_save_drops_a_kept_merged_transparency_marker() { + for (depth, marker) in [(SampleType::U8, *b"MTrn"), (SampleType::U8, *b"Mtrn"), (SampleType::U16, *b"Mt16"), (SampleType::F32, *b"Mt32")] { + let case = format!("{depth:?} {}", String::from_utf8_lossy(&marker)); + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 6), &[0.5, 0.2, 0.9, 1.0]); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", Surface::new(PixelFormat::GRAY8))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", marker, std::sync::Arc::new(Vec::new()))]; + let file = document_to_psd(&doc); + assert!(!file.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker), "{case}"); + assert!(!file.merged_has_alpha(), "{case}"); + let back = crate::import("a.psd", &file.to_bytes().unwrap()).unwrap().document; + let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["Alpha 1"], "{case}"); + } + } + #[test] fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { // #2700: a 16-bit file whose Lr16 block claims five layers but holds none. @@ -2087,6 +2387,42 @@ mod tests { assert_eq!(density, 0.5); } + #[test] + fn light_adjustment_is_kept_verbatim_and_warned() { + // #6542: Photoshop 2026's Light layer is a `brit` block whose `CgEd` descriptor carries + // more than Brightness/Contrast. Synthetic block: the extra key names are placeholders. + use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; + let brit = vec![0, 0, 0xff, 0xfe, 0, 127, 0, 0]; + let d = Descriptor::new("null") + .with("Vrsn", Value::Integer(1)) + .with("Brgh", Value::Integer(0)) + .with("Cntr", Value::Integer(-2)) + .with("Exps", Value::Double(-2.3)) + .with("Hghl", Value::Integer(-64)) + .with("useLegacy", Value::Boolean(false)); + let cged = VersionedDescriptor::new(d).to_bytes(); + let adjustment = crate::adjust_map::parse(b"brit", &brit, Some(&cged), crate::adjust_map::Channels::Rgb); + assert_eq!(adjustment, photocraft_doc::Adjustment::Unsupported { psd_key: "brit".into(), raw: brit.clone().into() }); + let mut doc = Document::with_background("Light", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8, photocraft_doc::Color::WHITE); + let mut layer = Layer::new("Light 1", LayerContent::Adjustment(adjustment)); + layer.psd_blocks = vec![(*b"brit", std::sync::Arc::new(brit.clone())), (*b"CgEd", std::sync::Arc::new(cged.clone()))]; + doc.layers.push(layer); + let block = |file: &PsdFile, key: &[u8; 4]| { + let records = &file.layer_info.as_ref().unwrap().layers; + records.iter().flat_map(|r| r.blocks.iter()).find(|b| b.key == *key).map(|b| b.data.clone()) + }; + let file = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + let (restored, warnings) = crate::psd_import::psd_to_document(&file); + assert!(warnings.iter().any(|w| w.contains("\"Light 1\"") && w.contains("Light settings")), "{warnings:?}"); + // Saved again unchanged, both blocks are written back byte for byte. + let again = PsdFile::from_bytes(&document_to_psd(&restored).to_bytes().unwrap()).unwrap(); + assert_eq!(block(&again, b"brit").as_deref(), Some(&brit[..])); + assert_eq!(block(&again, b"CgEd").as_deref(), Some(&cged[..])); + // A plain Brightness/Contrast descriptor still reads as Brightness/Contrast. + let plain = crate::adjust_map::write(&photocraft_doc::Adjustment::BrightnessContrast { brightness: 5.0, contrast: -2.0, legacy: false }); + assert!(!crate::adjust_map::is_extended_brit(Some(&plain[1].1))); + } + #[test] fn size_limit_boundary_keeps_limit_and_switches_above_it() { assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE))); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 159d90018c..21d14b4739 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -51,16 +51,27 @@ pub(crate) struct Ctx<'a> { pub cc: usize, pub cmyk: bool, pub warnings: Vec, + /// Decoding encountered damaged PSD data; interactive opens ask whether to continue. + pub damaged: bool, /// 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). pub txt2: Option, /// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id). pub filter_effects: Vec, + /// Filter masks already decoded, by placed id: every layer of a source reads the same item, and + /// a surface clones by sharing its tiles. + pub filter_masks: std::collections::HashMap>, + /// The same for the unfiltered pixels the mask mixes back in. + pub unfiltered: std::collections::HashMap>, + /// Pixel bytes retained in [`Self::unfiltered`], held under [`MAX_UNFILTERED_BYTES`]. + pub unfiltered_bytes: u64, /// Placed layers naming the same source share editable contents, not their placement. 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). @@ -132,6 +143,13 @@ impl Ctx<'_> { self.warnings.push(s.into()); } + /// Records a PSD decoding failure and marks the import as potentially incomplete. + /// Unlike compatibility warnings, this requires interactive confirmation. + fn warn_unreadable_data(&mut self, s: impl Into) { + self.damaged = true; + self.warn(s); + } + /// Source id, transform, (Distort/Perspective) projective placement and warp of a placed /// layer that has only a `PlLd` block, as Photoshop CS2 and older write it (#2465). `PlLd` /// stores the placed corners but not the source size, which comes from the embedded file. @@ -162,12 +180,13 @@ 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)?; match rec.decode_channel(id, self.file.header.depth, self.file.header.version) { Ok(p) => Some(p), Err(e) => { - self.warn(format!("layer \"{name}\": channel {id} could not be decoded ({e}); treated as empty")); + self.warn_unreadable_data(format!("layer \"{name}\": channel {id} could not be decoded ({e}); treated as empty")); None } } @@ -180,6 +199,8 @@ impl Ctx<'_> { rec.decode_channel(id, self.file.header.depth, self.file.header.version).ok() } + /// Builds a raster surface from the layer channels, leaving the surface empty if + /// any present channel fails to decode rather than inventing replacement pixels. fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface { let r = rec.rect; if r.is_empty() || r.size().is_err() { @@ -200,7 +221,7 @@ impl Ctx<'_> { let mut planes: Vec>> = Vec::with_capacity(decoded.len()); for (&id, p) in ids.iter().zip(decoded) { if p.is_none() && rec.channel(id).is_some() { - self.warn(format!("layer \"{name}\": channel {id} could not be decoded; treated as empty")); + self.warn_unreadable_data(format!("layer \"{name}\": channel {id} could not be decoded; treated as empty")); } planes.push(p); } @@ -214,7 +235,7 @@ impl Ctx<'_> { // Only decoded channel data justifies a buffer: the decoders bound their output by the // bytes in the file, but a bare rectangle can declare 300000² pixels in a few bytes (#755). if planes.iter().all(Option::is_none) { - self.warn(format!("layer \"{name}\": no channel data for its {w}x{h} bounds; treated as empty")); + self.warn_unreadable_data(format!("layer \"{name}\": no channel data for its {w}x{h} bounds; treated as empty")); return Surface::new(self.fmt); } let refs: Vec> = planes.iter().map(|p| p.as_deref()).collect(); @@ -231,6 +252,8 @@ impl Ctx<'_> { surf } + /// Restores the selected user mask from its channel and metadata, recording an + /// integrity failure if the mask channel cannot be decoded. fn record_mask(&mut self, rec: &LayerRecord, name: &str) -> Option { let m = rec.layer_mask()?; let (id, rect, default, flags) = match selected_real_mask(rec) { @@ -264,14 +287,19 @@ impl Ctx<'_> { /// document coordinates, white beyond its bounds (`filterMaskExtendWithWhite`). An all-white /// mask is no mask. fn filter_mask(&mut self, placed: &str, stack: &crate::smart_map::FilterStack, name: &str) -> Option { + if let Some(done) = self.filter_masks.get(placed) { + return done.clone(); + } let item = self.filter_effects.iter().find(|i| i.id == placed)?; - match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) { + let mask = match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) { Ok(m) => m, Err(e) => { self.warn(format!("layer \"{name}\": {e}; the filter mask was ignored")); None } - } + }; + self.filter_masks.insert(placed.to_string(), mask.clone()); + mask } fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option) { @@ -330,6 +358,19 @@ impl Ctx<'_> { // Blend If lives in the layer record's blending ranges. l.blend_if = crate::blocks::blend_if_from_ranges(&rec.blending_ranges); l.psd_id = rec.layer_id(); + // The layer's XMP packet (`shmd` › `cust` › `layerXMP`) becomes the field; the rest of + // `shmd` stays in `psd_blocks`, where export puts the packet back. + if let Some(i) = l.psd_blocks.iter().position(|(k, _)| k == b"shmd") + && let Some((xmp, rest)) = photocraft_psd::metadata::take_layer_xmp(&l.psd_blocks[i].1) + { + l.xmp = Some(Arc::from(xmp)); + match rest { + Some(data) => l.psd_blocks[i].1 = Arc::new(data), + None => { + l.psd_blocks.remove(i); + } + } + } let name = l.name.clone(); l.mask = self.record_mask(rec, &name); } @@ -364,10 +405,16 @@ impl Ctx<'_> { // Kept verbatim for saving, but it renders as nothing: say so rather than open the // layer silently without its effect (#1763). if matches!(adj, photocraft_doc::Adjustment::Unsupported { .. }) { - let why = adjust_map::unreadable_reason(k, &data).map(|r| format!(" ({r})")).unwrap_or_default(); + // Photoshop 2026's Light adjustment shares the Brightness/Contrast key (#6542). + let light = k == b"brit" && adjust_map::is_extended_brit(cged); + let why = if light { + " (Light adjustments, with Exposure, Highlights, Shadows, Whites and Blacks, aren't supported yet)".to_string() + } else { + adjust_map::unreadable_reason(k, &data).map(|r| format!(" ({r})")).unwrap_or_default() + }; self.warn(format!( "layer \"{name}\": its {} settings could not be read{why}; the layer is kept as saved but has no effect", - adjust_map::label(k) + if light { "Light" } else { adjust_map::label(k) } )); } LayerContent::Adjustment(adj) @@ -385,10 +432,17 @@ impl Ctx<'_> { }) .flatten(); if let Some(mut t) = parsed { + let mut path_error = None; if let Some(txt2) = &self.txt2 { photocraft_text::psd::apply_txt2(&mut t, &block.data, txt2); + // Type on a path keeps its path only in its `Txt2` frame (#7962). + path_error = photocraft_text::psd::apply_txt2_path(&mut t, &block.data, txt2).err(); photocraft_text::psd::apply_txt2_frame(&mut t, &block.data, txt2); } + if let Some(e) = path_error { + let shape = if matches!(t.shape, photocraft_doc::text::TextShape::Box { .. }) { "paragraph" } else { "point" }; + self.warn(format!("layer \"{name}\": its type path could not be read ({e}); it opens as {shape} text")); + } // Photoshop's pixels stay until edited. Writers without type pixels are // rendered by text_import::prepare instead of retaining an empty cache. let cache = self.record_surface(rec, &name); @@ -426,7 +480,24 @@ impl Ctx<'_> { let shown = stack.enabled && stack.filters.iter().any(|f| f.visible); let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) { (Some(p), Some(m)) => { - let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt)); + let unfiltered = match self.unfiltered.get(&p.placed) { + Some(done) => done.clone(), + None => { + // Every distinct source is kept until the import ends, so what they add up to is + // bounded: past the limit the layer keeps its stored pixels, unmixed. + let item = self.filter_effects.iter().find(|i| i.id == p.placed); + let cost = item + .and_then(|i| i.rect.size().ok()) + .map_or(0, |(w, h)| (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.bytes_per_pixel() as u64)); + let fits = self.unfiltered_bytes.saturating_add(cost) <= MAX_UNFILTERED_BYTES; + let made = item.filter(|_| fits).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt)); + if made.is_some() { + self.unfiltered_bytes += cost; + } + self.unfiltered.insert(p.placed.clone(), made.clone()); + made + } + }; match unfiltered { Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m), None => stored, @@ -434,9 +505,16 @@ impl Ctx<'_> { } _ => stored, }; + let vector_preview = rec.block(&crate::vector_preview::PSD_KEY).and_then(|block| { + let file = self.file; + self.vector_previews.decode(&id, &block.data, || { + crate::linked::embedded_payload_in_blocks(file.global_blocks.iter().map(|b| (&b.key, b.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), @@ -492,7 +570,7 @@ impl Ctx<'_> { self.warn(format!( "layer \"{name}\": its adjustment settings could not be read (unknown adjustment type \"{key}\"); the layer is kept as saved but has no effect" )); - let raw = rec.block(&k).map(|b| b.data.clone()).unwrap_or_default(); + let raw = rec.block(&k).map(|b| b.data.as_slice()).unwrap_or_default().into(); LayerContent::Adjustment(photocraft_doc::Adjustment::Unsupported { psd_key: key, raw }) } else { LayerContent::Raster(self.record_surface(rec, &name)) @@ -627,6 +705,9 @@ pub(crate) fn group_depth(file: &PsdFile) -> usize { /// Additional-layer-information keys (Adobe PSD spec, plus `cust`, `vowv` and `SoLE` seen in /// Photoshop's files) that are not an adjustment of their own: names, ids, locks, effects, /// masks, metadata, and the fill, type, placed and shape content read before. +/// Most pixel bytes the smart-filter `FEid` sources may keep decoded across one import. +const MAX_UNFILTERED_BYTES: u64 = 1 << 30; + const LAYER_INFO_KEYS: [&[u8; 4]; 50] = [ b"luni", b"lyid", b"lnsr", b"lsct", b"lsdk", b"lspf", b"lclr", b"iOpa", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"brst", b"shmd", b"fxrp", b"lfx2", b"lfxs", b"lmfx", b"lrFX", b"lyvr", b"sn2P", b"cust", b"CgEd", b"vmsk", b"vsms", b"vogk", b"vowv", b"vstk", b"vscg", b"artb", b"artd", b"abdd", @@ -659,7 +740,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } -fn unicode_names(data: &[u8]) -> Vec { +pub(crate) fn unicode_names(data: &[u8]) -> Vec { let mut out = Vec::new(); let mut at = 0; while at + 4 <= data.len() { @@ -798,6 +879,7 @@ fn mark_background(file: &PsdFile, layers: &mut [Layer]) { } } +/// Converts a parsed PSD into a document, preserving best-effort recovery warnings. pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { // Never cancelled, so always `Some`; the fallback is unreachable. psd_to_document_with(file, &photocraft_raster::Interrupt::NONE) @@ -810,6 +892,69 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) psd_to_document_decoded(file, ctl, None) } +/// Whether `file` stores spot planes the way earlier PhotoCraft versions (releases 0.1.0 to +/// 0.16.0) wrote them: straight (the maximum = solid ink), the opposite of Photoshop, which +/// stores 0 = solid ink in every PSD and TIFF it saves. Their files are recognised by three +/// things together: +/// +/// - DisplayInfo (1077) marks at least one spot channel; +/// - there is no Alpha Identifiers resource (1053), or one that lists fewer channels than the +/// file has ([`photoshop_identified_channels`]). Photoshop has written it, one identifier per +/// extra channel, in every file with an extra channel since version 6, and PhotoCraft writes it +/// now. No earlier PhotoCraft wrote one of its own, but each kept the 1053 of a Photoshop file +/// it opened (a transparent document's `[0]`, for one) and wrote it back unchanged, after the +/// channels added in it, which it stored straight; +/// - the version info (1057) is byte for byte what those versions wrote: writer "Adobe Photoshop" +/// with reader "Adobe Photoshop CS6", both NUL-terminated (0.1.0 to 0.7.0), or reader "Adobe +/// Photoshop 2026" without a NUL (0.8.2 on), with either merged-data flag. +/// +/// Any other file is read in Photoshop's polarity. psd-tools' `cmyk-spot.psd`, for one, also has +/// spots and no 1053, but names psd-tools in 1057 and stores Photoshop's polarity. A Photoshop +/// file that an earlier PhotoCraft opened and saved again kept Photoshop's 1053 and its planes as +/// Photoshop stored them, so it is read in Photoshop's polarity too; when channels were added +/// there, only the planes after the ones the kept 1053 lists are read straight. +/// +/// Keeping these files' ink is the only reason this exists: deleting it (and its one caller) +/// reads every file in Photoshop's polarity, at the cost of inverting the spot ink of PSDs saved +/// by those versions. +pub(crate) fn earlier_photocraft_spot_planes(file: &PsdFile) -> bool { + use photocraft_psd::descriptor::UnicodeString; + use photocraft_psd::resources::VersionInfo; + + let marks_spot = file.resource(crate::channel_map::DISPLAY_INFO).is_some_and(|r| crate::channel_map::display_info_marks_spot(&r.data)); + let identifies_every_channel = match file.header.color_mode.color_channels() { + Some(color) => { + let channels = file.header.channels.saturating_sub(color).saturating_sub(u16::from(file.merged_has_alpha())); + photoshop_identified_channels(file) >= usize::from(channels) + } + // Multichannel has no fixed number of colour channels to count from. + None => file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some(), + }; + if !marks_spot || identifies_every_channel { + return false; + } + let Some(r) = file.resource(ids::VERSION_INFO) else { return false }; + // Byte for byte: a resource with anything after the fields is someone else's. + let Ok(v) = VersionInfo::from_bytes(&r.data) else { return false }; + if v.to_bytes() != r.data { + return false; + } + let writers = [ + (UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6")), + (UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop 2026")), + ]; + v.version == 1 && v.file_version == 1 && writers.iter().any(|(writer, reader)| v.writer == *writer && v.reader == *reader) +} + +/// How many extra channels, after the merged transparency, the Alpha Identifiers resource (1053) +/// lists: its identifiers other than 0, which Photoshop gives the merged transparency (first, and +/// in some files with no transparency plane at all). 0 without a 1053. In a file from an earlier +/// PhotoCraft ([`earlier_photocraft_spot_planes`]) these are the channels of the Photoshop file +/// it opened, stored as Photoshop stored them; the channels after them were added there. +pub(crate) fn photoshop_identified_channels(file: &PsdFile) -> usize { + file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map_or(0, |r| r.data.as_chunks::<4>().0.iter().filter(|id| **id != [0; 4]).count()) +} + /// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* /// the image), a file without layers, or extra alpha/spot channels behind the colour ones. pub(crate) fn merged_needed(file: &PsdFile) -> bool { @@ -833,6 +978,17 @@ pub(crate) fn psd_to_document_decoded( ctl: &photocraft_raster::Interrupt, decoded: Option>>, ) -> Option<(Document, Vec)> { + psd_to_document_decoded_integrity(file, ctl, decoded).map(|(document, warnings, _)| (document, warnings)) +} + +/// Also reports whether unrecoverable channel, layer or merged-image data was discarded. +/// Interactive opens use this to ask whether to continue. Both ordinary imports and +/// the low-level conversion API retain their existing best-effort behaviour. +pub(crate) fn psd_to_document_decoded_integrity( + file: &PsdFile, + ctl: &photocraft_raster::Interrupt, + decoded: Option>>, +) -> Option<(Document, Vec, bool)> { let h = &file.header; let mut warnings = Vec::new(); let mode = doc_mode(h.color_mode).unwrap_or_else(|| { @@ -857,7 +1013,7 @@ pub(crate) fn psd_to_document_decoded( } } ids::ICC_PROFILE => doc.icc_profile = Some(Arc::new(r.data.clone())), - ids::XMP => doc.metadata.xmp = Some(String::from_utf8_lossy(&r.data).into_owned()), + ids::XMP => doc.metadata.xmp = Some(String::from_utf8_lossy(&r.data).into()), ids::EXIF => doc.metadata.exif = Some(Arc::new(r.data.clone())), 1032 => parse_guides(&r.data, &mut doc), ids::GLOBAL_ANGLE | ids::GLOBAL_ALTITUDE => { @@ -888,6 +1044,10 @@ pub(crate) fn psd_to_document_decoded( doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))); } + // Resource 1053 is written afresh with the extra channels on every save, so a kept copy + // would be new metadata after each round trip. Multichannel has no such rewrite: the + // imported bytes are the channel ids, copied back while they still match. + crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} id if MAPPED_RESOURCES.contains(&id) => {} id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } @@ -899,7 +1059,9 @@ pub(crate) fn psd_to_document_decoded( // A 16/32-bit file whose layer block can't be parsed would otherwise open as a flattened // file without a word (#2700). The raw block is kept, but a PSD save writes the document's // own layers in its place (see `psd_export`). + let mut damaged = false; if let Some((b, e)) = file.unreadable_layer_block() { + damaged = true; warnings.push(format!( "the layers ({} block) could not be read ({e}); only the merged image was opened. Saving as PSD replaces the unreadable layer data", b.key_str() @@ -924,11 +1086,16 @@ pub(crate) fn psd_to_document_decoded( cc, cmyk: fmt.mode == ColorMode::Cmyk, warnings, + damaged, dpi: doc.resolution_dpi, txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)), 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: *ctl, done: 0, total: file.layers().len(), @@ -963,15 +1130,15 @@ pub(crate) fn psd_to_document_decoded( } ctl.progress(0.1); if let Err(e) = &m { - cx.warn(format!("merged image could not be decoded: {e}")); + cx.warn_unreadable_data(format!("merged image could not be decoded: {e}")); if can_recover_layers(file) { - cx.warn("extra alpha/spot channels in the merged image could not be recovered"); + cx.warn_unreadable_data("extra alpha/spot channels in the merged image could not be recovered"); } } Some(m) } else { if let Some(Err(e)) = decoded { - cx.warn(format!("merged image could not be decoded; opened the available layers instead: {e}")); + cx.warn_unreadable_data(format!("merged image could not be decoded; opened the available layers instead: {e}")); } None }; @@ -1074,7 +1241,7 @@ pub(crate) fn psd_to_document_decoded( cx.warn("duotone inks are not interpreted (shown as grayscale)"); } let inks = inks.unwrap_or_else(|| vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])]); - doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.clone()) }); + doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.as_slice().into()) }); } else if !file.color_mode_data.is_empty() { cx.warn("color mode data is not preserved"); } @@ -1100,7 +1267,7 @@ pub(crate) fn psd_to_document_decoded( } // A merged image that failed to decode was reported above. Err(_) if matches!(merged, Some(Err(_))) => {} - Err(e) => cx.warn(format!("the image could not be read: {e}")), + Err(e) => cx.warn_unreadable_data(format!("the image could not be read: {e}")), } } @@ -1114,10 +1281,29 @@ pub(crate) fn psd_to_document_decoded( // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); let names = names.get(skip..).unwrap_or_default(); + // Which planes hold spot ink in Photoshop's polarity (0 = solid): the DisplayInfo records + // the channel options are read from below, the transparency's first when `skip` is 1. + // A file from an earlier PhotoCraft stores them straight and is read as it was written, + // past the channels of the Photoshop file it opened, if any. + let straight_from = if earlier_photocraft_spot_planes(file) { photoshop_identified_channels(file) } else { usize::MAX }; + let photoshop_spots = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, skip) + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, skip) + } else { + Vec::new() + }; for (k, idx) in (first..usize::from(h.channels)).enumerate() { - let Some(p) = merged_plane(all, idx, plane) else { break }; + let Some(p) = merged_plane(all, idx, plane) else { + cx.warn_unreadable_data(format!("merged extra channel {idx} is incomplete and was not imported")); + break; + }; + let mut raw = be_to_ne_plane(p, depth, n); + if k < straight_from && photoshop_spots.get(k).copied().unwrap_or(false) { + crate::channel_map::photoshop_spot_plane(&mut raw, depth); + } let mut s = Surface::new(cx.mask_fmt); - s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); + s.write_interleaved(canvas, &raw); s.prune(); let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1)); doc.channels.push(AlphaChannel::new(name, s)); @@ -1153,13 +1339,15 @@ pub(crate) fn psd_to_document_decoded( return None; } ctl.progress(1.0); - Some((doc, cx.warnings)) + Some((doc, cx.warnings, cx.damaged)) } #[cfg(test)] mod tests { use super::*; - use photocraft_psd::{Compression, PsdError, Version, testgen}; + use photocraft_psd::descriptor::UnicodeString; + use photocraft_psd::resources::VersionInfo; + use photocraft_psd::{Compression, ImageData, ImageResource, PsdError, Version, testgen}; /// #5200: the Multichannel fallback checks its RGBA output against the budget before /// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes). @@ -1198,6 +1386,64 @@ mod tests { assert_eq!(unicode_names(&data), ["A"]); } + /// Verifies an unreadable layer channel sets the structured damage indicator. + #[test] + fn unreadable_layer_channel_marks_conversion_damaged() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let channel = file.layers_mut()[0].channels.iter_mut().find(|c| c.id == 0).unwrap(); + channel.data.clear(); + assert!(file.layers()[0].decode_channel(0, file.header.depth, file.header.version).is_err()); + + let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap(); + assert!(damaged); + assert!(warnings.iter().any(|w| w.contains("could not be decoded"))); + } + + /// Verifies an unreadable user-mask channel sets the structured damage indicator. + #[test] + fn unreadable_mask_channel_marks_conversion_damaged() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let channel = file.layers_mut()[0].channels.iter_mut().find(|c| c.id == CHANNEL_USER_MASK).unwrap(); + channel.data.clear(); + + let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap(); + assert!(damaged); + assert!(warnings.iter().any(|w| w.contains("could not be decoded"))); + } + + /// A layer whose bounds hold pixels but whose colour and transparency channels are missing + /// lost its pixel data: the open asks before continuing. + #[test] + fn layer_bounds_without_channel_data_mark_conversion_damaged() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + file.layers_mut()[0].channels.retain(|c| c.id == CHANNEL_USER_MASK); + + let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap(); + assert!(damaged); + assert!(warnings.iter().any(|w| w.contains("no channel data"))); + } + + /// A layer without pixels (an empty layer, or a text or shape layer written without a + /// pixel cache) has empty bounds: nothing is missing, so the file is not damaged even when + /// it lists no colour or transparency channel. + #[test] + fn empty_layer_without_channel_data_is_not_damaged() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + file.layers_mut()[0].channels.retain(|c| c.id == CHANNEL_USER_MASK); + file.layers_mut()[0].rect = Default::default(); + + let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap(); + assert!(!damaged, "an empty layer marked the PSD damaged: {warnings:?}"); + } + + /// Verifies healthy layer channels do not mark the PSD as damaged. + #[test] + fn valid_layer_channels_keep_conversion_editable() { + let file = testgen::small(Version::Psd, Compression::Raw); + let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap(); + assert!(!damaged, "unexpectedly marked good PSD damaged: {warnings:?}"); + } + #[test] fn merged_plane_checks_offsets() { let all = [1u8, 2, 3, 4]; @@ -1205,4 +1451,266 @@ mod tests { assert_eq!(merged_plane(&all, 2, 2), None); assert_eq!(merged_plane(&all, usize::MAX, 2), None); } + + /// Resource 1057's data. + fn version_info(writer: UnicodeString, reader: UnicodeString, has_real_merged_data: bool) -> Vec { + VersionInfo { version: 1, has_real_merged_data, writer, reader, file_version: 1 }.to_bytes() + } + + /// The 1057 PhotoCraft 0.1.0 to 0.7.0 wrote: Photoshop CS6's names, NUL-terminated. + fn version_info_cs6(has_real_merged_data: bool) -> Vec { + version_info(UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6"), has_real_merged_data) + } + + /// The 1057 PhotoCraft writes since 0.8.2: Photoshop 2026's bytes, no NUL. + fn version_info_2026(has_real_merged_data: bool) -> Vec { + photocraft_psd::resources::version_info_resource(has_real_merged_data).data + } + + /// An opaque 4 × 1 RGB document with the Photoshop spot oracles' channels in their order: + /// "Spot A", "Alpha 1" and "Spot B", each holding 100, 25, 75 and 0 % in four columns. + fn channels_document(depth: SampleType) -> Document { + let mut doc = Document::with_background("old", Size::new(4, 1), ColorMode::Rgb, depth, photocraft_color::Color::rgb(1.0, 1.0, 1.0)); + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let canvas = doc.bounds(); + let channel = |name: &str, spot: Option<(photocraft_color::Color, f32)>| { + let mut s = Surface::new(gray); + s.write_region(canvas, &[1.0, 0.25, 0.75, 0.0]); + AlphaChannel { spot, ..AlphaChannel::new(name, s) } + }; + doc.channels.push(channel("Spot A", Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), 0.0)))); + doc.channels.push(channel("Alpha 1", None)); + doc.channels.push(channel("Spot B", Some((photocraft_color::Color::rgb(1.0, 0.0, 0.627), 0.5)))); + doc + } + + /// Each channel's values on an 8-bit scale. + fn ink(doc: &Document) -> Vec> { + doc.channels.iter().map(|c| c.surface.read_region(doc.bounds()).iter().map(|v| (v * 255.0).round() as u8).collect()).collect() + } + + fn spots(doc: &Document) -> Vec { + doc.channels.iter().map(|c| c.spot.is_some()).collect() + } + + /// `doc` (opaque, 8 or 16-bit) saved the way PhotoCraft 0.1.0 to 0.16.0 saved it: spot planes + /// straight (the maximum = solid ink), no 1053, and `version_info` as resource 1057. `padded` + /// lays DisplayInfo out in the 14-byte records of 0.1.0 to 0.7.0. + fn as_earlier_photocraft_saved(doc: &Document, version_info: Vec, padded: bool) -> Vec { + let mut file = crate::psd_export::document_to_psd(doc); + assert!(!file.merged_has_alpha()); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + for r in &mut file.resources { + match r.id { + ids::VERSION_INFO => r.data = version_info.clone(), + crate::channel_map::DISPLAY_INFO if padded => { + let (version, records) = r.data.split_at(4); + r.data = version.iter().copied().chain(records.chunks(13).flat_map(|rec| rec.iter().copied().chain([0]))).collect(); + } + _ => {} + } + } + let mut planes = file.decode_merged().unwrap(); + let plane = planes.len() / usize::from(file.header.channels); + for (k, c) in doc.channels.iter().enumerate() { + if c.spot.is_some() { + // At 8 and 16 bits the complement of a sample is that of each of its bytes. + planes[(3 + k) * plane..(4 + k) * plane].iter_mut().for_each(|b| *b = !*b); + } + } + file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap(); + file.to_bytes().unwrap() + } + + /// PSDs that PhotoCraft 0.1.0 to 0.16.0 saved with spot channels store them straight. They + /// open with the ink they had in those versions (which read every plane straight), whichever + /// 1057 they carry. Saved again, they get Photoshop's polarity and a 1053, and reopen with the + /// same ink. (A 0.1.0 to 0.7.0 file's padded records misread every channel's options after + /// the first, here Spot B's kind, as those versions' successors always have; its ink is kept.) + #[test] + fn spot_planes_from_earlier_photocraft_keep_their_ink() { + for depth in [SampleType::U8, SampleType::U16] { + let doc = channels_document(depth); + let cases = [ + ("0.1.0 to 0.7.0", version_info_cs6(true), true), + ("0.8.2 on", version_info_2026(true), false), + ("0.8.2 on, Maximize Compatibility off", version_info_2026(false), false), + ("0.1.0 to 0.7.0, 13-byte records", version_info_cs6(true), false), + ]; + for (what, version_info, padded) in cases { + let bytes = as_earlier_photocraft_saved(&doc, version_info, padded); + assert!(earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}"); + let opened = crate::import("old.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{depth:?} {what}: the ink earlier versions opened"); + assert!(spots(&opened)[0], "{depth:?} {what}"); + if !padded { + assert_eq!(spots(&opened), spots(&doc), "{depth:?} {what}"); + } + let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap(); + let file = PsdFile::from_bytes(&saved.bytes).unwrap(); + assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some() && !earlier_photocraft_spot_planes(&file), "{what}"); + let again = crate::import("again.psd", &saved.bytes).unwrap().document; + assert_eq!(ink(&again), ink(&doc), "{depth:?} {what}: saved again"); + assert_eq!(spots(&again), spots(&opened), "{depth:?} {what}: saved again"); + } + } + } + + /// A 0.1.0 to 0.7.0 file whose first extra channel is an alpha channel, spots after it. In its + /// 14-byte DisplayInfo records the spots' kind bytes sit where a 13-byte reader (main's, and + /// this one's) doesn't look, so only the padded reading finds a spot for the rule. Every plane + /// is then read straight, as those versions read it, and keeps its ink. The 13-byte reader + /// opens each channel's options from shifted bytes, as on main: here every channel opens as + /// an alpha channel. A spot of 2 % solidity puts a 2 where that reader looks for the kind, so + /// that channel opens as a spot: without the rule its plane would be inverted. Saved again and + /// reopened, every channel keeps its ink and kind. + #[test] + fn an_early_file_with_an_alpha_channel_first_keeps_its_ink() { + for solidity in [0.0, 0.02] { + let mut doc = channels_document(SampleType::U8); + doc.channels.swap(0, 1); + doc.channels[1].spot = Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), solidity)); + let names: Vec<&str> = doc.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["Alpha 1", "Spot A", "Spot B"]); + let bytes = as_earlier_photocraft_saved(&doc, version_info_cs6(true), true); + let file = PsdFile::from_bytes(&bytes).unwrap(); + let info = &file.resource(crate::channel_map::DISPLAY_INFO).unwrap().data; + let misread = solidity > 0.0; + assert_eq!(crate::channel_map::spot_flags(info, true, 0), [false, misread, false], "{solidity}: 13-byte records"); + assert!(earlier_photocraft_spot_planes(&file), "{solidity}: found in the 14-byte records"); + let opened = crate::import("old.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{solidity}: the ink 0.7.0 opened"); + assert_eq!(spots(&opened), [false, misread, false], "{solidity}: kinds as main reads them"); + let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap(); + let again = crate::import("again.psd", &saved.bytes).unwrap().document; + assert_eq!(ink(&again), ink(&doc), "{solidity}: saved again"); + assert_eq!(spots(&again), spots(&opened), "{solidity}: saved again"); + } + } + + /// A file with spots and no 1053 that no earlier PhotoCraft wrote is read in Photoshop's + /// polarity (0 = solid ink). psd-tools' `cmyk-spot.psd` is one: its 1057 names psd-tools as + /// writer and reader. So is a file with no 1057, with Photoshop CS6's names without the NUL, + /// or with bytes after PhotoCraft's. + #[test] + fn other_files_without_alpha_identifiers_use_photoshop_polarity() { + let doc = channels_document(SampleType::U8); + let mut trailing = version_info_2026(true); + trailing.extend([0, 0]); + let cases = [ + ("psd-tools", Some(version_info(UnicodeString::new("psd-tools 1.10.0"), UnicodeString::new("psd-tools 1.10.0"), true))), + ("no 1057", None), + ("CS6 without a NUL", Some(version_info(UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop CS6"), true))), + ("bytes after PhotoCraft's 1057", Some(trailing)), + ]; + for (what, info) in cases { + // Photoshop's polarity, as this version writes it, without 1053. + let mut file = crate::psd_export::document_to_psd(&doc); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO); + file.resources.extend(info.map(|d| ImageResource::new(ids::VERSION_INFO, d))); + let bytes = file.to_bytes().unwrap(); + assert!(!earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}"); + let opened = crate::import("x.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{what}"); + assert_eq!(spots(&opened), spots(&doc), "{what}"); + } + } + + /// The rule needs a spot in DisplayInfo and no 1053, besides PhotoCraft's 1057. + #[test] + fn the_earlier_photocraft_rule_needs_a_spot_and_no_alpha_identifiers() { + let mut doc = channels_document(SampleType::U8); + let old = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap(); + assert!(earlier_photocraft_spot_planes(&old)); + let mut with_ids = old.clone(); + with_ids.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, 3))); + assert!(!earlier_photocraft_spot_planes(&with_ids), "a 1053"); + let mut no_info = old.clone(); + no_info.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO); + assert!(!earlier_photocraft_spot_planes(&no_info), "no DisplayInfo"); + doc.channels.retain(|c| c.spot.is_none()); + let alpha_only = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap(); + assert!(!earlier_photocraft_spot_planes(&alpha_only), "alpha channels only"); + } + + /// `doc` as an earlier PhotoCraft saved a Photoshop file it had opened with the first `kept` + /// of `doc`'s channels: it wrote back the Photoshop file's 1053, `stale_ids`, unchanged and + /// those channels' planes as Photoshop stored them (it had read and written them straight), + /// and stored the channels added after them straight. + fn as_earlier_photocraft_resaved(doc: &Document, stale_ids: &[u32], kept: usize) -> Vec { + let mut file = crate::psd_export::document_to_psd(doc); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO); + file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, stale_ids.iter().flat_map(|id| id.to_be_bytes()).collect())); + file.resources.push(ImageResource::new(ids::VERSION_INFO, version_info_2026(true))); + let mut planes = file.decode_merged().unwrap(); + let plane = planes.len() / usize::from(file.header.channels); + let first = usize::from(file.header.channels) - doc.channels.len(); + for (k, c) in doc.channels.iter().enumerate().skip(kept) { + if c.spot.is_some() { + planes[(first + k) * plane..(first + k + 1) * plane].iter_mut().for_each(|b| *b = !*b); + } + } + file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap(); + file.to_bytes().unwrap() + } + + /// An earlier PhotoCraft kept the 1053 of a Photoshop file it opened and wrote it back + /// unchanged, so a spot channel added there sits behind a 1053 that doesn't list it: a + /// transparent Photoshop document's `[0]` (the white plate of a DTF print, added before + /// saving), the `[0, 3]` of one with a spot channel already, or a `[0]` kept through a + /// flatten that removed the transparency. The added spot is stored straight and keeps its + /// ink; the Photoshop file's own spot keeps Photoshop's polarity. Saved again, both reopen + /// the same. + #[test] + fn a_spot_added_behind_a_kept_alpha_identifiers_resource_keeps_its_ink() { + let transparent = |names: &[&str]| { + let mut doc = channels_document(SampleType::U8); + doc.layers = vec![Layer::raster("Layer 1", doc.pixel_format())]; + doc.channels.retain(|c| names.contains(&c.name.as_str())); + doc + }; + let mut flattened = channels_document(SampleType::U8); + flattened.channels.retain(|c| c.name == "Spot A"); + let cases = [ + ("transparent, one spot added", transparent(&["Spot A"]), vec![0], 0), + ("transparent, a Photoshop spot and one added", transparent(&["Spot A", "Spot B"]), vec![0, 3], 1), + ("flattened, one spot added", flattened, vec![0], 0), + ]; + for (what, doc, stale_ids, kept) in cases { + let bytes = as_earlier_photocraft_resaved(&doc, &stale_ids, kept); + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(file.merged_has_alpha(), what.starts_with("transparent"), "{what}"); + assert!(earlier_photocraft_spot_planes(&file), "{what}"); + let opened = crate::import("old.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{what}: the ink earlier versions opened"); + assert_eq!(spots(&opened), spots(&doc), "{what}"); + let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap(); + let again = crate::import("again.psd", &saved.bytes).unwrap().document; + assert_eq!(ink(&again), ink(&doc), "{what}: saved again"); + } + } + + /// A 1053 that lists every extra channel, as Photoshop and this version write it, keeps the + /// file in Photoshop's polarity, with PhotoCraft's (Photoshop 2026's) 1057 too: a transparent + /// file with a spot, and Photoshop's own `[0]` in a file without a transparency plane. + #[test] + fn a_complete_alpha_identifiers_resource_keeps_photoshop_polarity() { + let mut doc = channels_document(SampleType::U8); + doc.layers = vec![Layer::raster("Layer 1", doc.pixel_format())]; + let bytes = as_earlier_photocraft_resaved(&doc, &[0, 3, 4, 5], 3); + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert!(file.merged_has_alpha()); + assert!(!earlier_photocraft_spot_planes(&file)); + assert_eq!(ink(&crate::import("ps.psd", &bytes).unwrap().document), ink(&doc)); + let mut opaque = channels_document(SampleType::U8); + opaque.channels.clear(); + let mut file = PsdFile::from_bytes(&as_earlier_photocraft_resaved(&opaque, &[0], 0)).unwrap(); + file.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO); + file.resources.push(ImageResource::new( + crate::channel_map::DISPLAY_INFO, + crate::channel_map::display_info(&channels_document(SampleType::U8).channels.iter().collect::>(), false), + )); + assert_eq!(photoshop_identified_channels(&file), 0, "0 is the transparency's"); + assert!(!earlier_photocraft_spot_planes(&file), "no channel left unlisted"); + } } diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 820de8935b..a1ed4aaa01 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType}; +pub use photocraft_raw::Demosaic; use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance}; pub use photocraft_raw::{EXTENSIONS, Tone, retone}; @@ -49,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) -> /// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, /// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the -/// developed RGB pixels. +/// developed RGB pixels, and the demosaic method. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -58,6 +59,7 @@ pub struct RawTuning { pub tint: f32, /// EV, as `CameraRaw::exposure`. pub exposure: f32, + pub demosaic: Demosaic, /// −100…100, as `CameraRaw::shadows`: in linear light before the tone curve, as Camera Raw /// does for raw files ([`photocraft_raw::Tone`]). pub shadows: f32, @@ -95,7 +97,7 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< // The colour rendering the default open used (a look fitted to the camera's JPEG for files // without colour calibration), so re-developing changes only what the sliders change. let camera_look = default_look(bytes, &sensor); - let mut opts = DevelopOptions { limits: limits(), exposure, tone, camera_look, ..Default::default() }; + let mut opts = DevelopOptions { limits: limits(), exposure, demosaic: tuning.demosaic, tone, camera_look, ..Default::default() }; let wb_applied = match sensor.camera_wb { Some(m) => { let g = wb_gains(tuning.temperature, tuning.tint); @@ -115,7 +117,7 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< .filter(|(_, v)| *v != 0.0) .map(|(k, v)| format!(", {k} {v:+.0}")) .collect::(); - let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb})", tuning.exposure); + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id()); Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied)) } @@ -188,7 +190,12 @@ fn developed_document(name: &str, bytes: &[u8], format: &str, dev: photocraft_ra /// Develops a raw file with the default settings. pub fn import_raw(name: &str, bytes: &[u8]) -> Result { - import_raw_with(name, bytes, &DevelopOptions { limits: limits(), ..Default::default() }) + import_raw_demosaic(name, bytes, Demosaic::default()) +} + +/// Develops a raw file with the default settings and the `demosaic` method. +pub fn import_raw_demosaic(name: &str, bytes: &[u8], demosaic: Demosaic) -> Result { + import_raw_with(name, bytes, &DevelopOptions { limits: limits(), demosaic, ..Default::default() }) } /// Smallest preview long edge preferred for the colour fit (a smaller preview decodes faster; @@ -254,6 +261,16 @@ fn develop_fitted(bytes: &[u8], opts: &DevelopOptions) -> Result Option { + let note = warnings.iter().find(|w| w.contains(DEVELOPED_NOTE))?; + let after = ¬e[note.find(DEVELOPED_NOTE)? + DEVELOPED_NOTE.len()..]; + let id = after.trim_start().strip_prefix('(')?.split(" demosaic").next()?; + Demosaic::from_id(id) +} + /// Develops a raw file with explicit settings. Without `opts.camera_look`, a file without /// colour calibration gets one fitted to its embedded JPEG when that clearly helps. pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Result { @@ -263,29 +280,64 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul let summary = format!("{DEVELOPED_NOTE} ({} demosaic, as-shot white balance)", opts.demosaic.id()); developed_document(name, bytes, format, dev, &summary) } - Err(RawError::Unsupported(reason)) => match photocraft_raw::embedded_preview(bytes) { - Some(p) => { - let img = upright_preview(bytes, p.jpeg, &opts.limits)?; - let mut r = image_to_document(name, &img)?; - r.warnings.insert( - 0, - format!( - "{format}: {reason} is not supported yet; opened the camera's embedded {}x{} JPEG preview instead (8-bit, not the raw sensor data)", - p.width, p.height - ), - ); - Ok(r) - } - None => Err(IoError::Raw(RawError::Unsupported(reason))), - }, + Err(RawError::Unsupported(reason)) => { + let jpeg = photocraft_raw::embedded_preview(bytes); + // A DNG 1.7 converter keeps only its small thumbnail as JPEG and writes the larger + // previews as JPEG XL, like the raw data (#8125): open the largest of either kind. + let jpeg_area = jpeg.map_or(0, |p| u64::from(p.width) * u64::from(p.height)); + let jxl = photocraft_raw::embedded_jxl_preview(bytes, &opts.limits).filter(|p| u64::from(p.width) * u64::from(p.height) > jpeg_area); + let (img, kind, (w, h)) = match (jxl, jpeg) { + (Some(p), _) => { + let size = (p.width, p.height); + (jxl_preview_image(bytes, p)?, "JPEG XL", size) + } + (None, Some(p)) => (upright_preview(bytes, p.jpeg, &opts.limits)?, "JPEG", (p.width, p.height)), + (None, None) => return Err(IoError::Raw(RawError::Unsupported(reason))), + }; + let mut r = image_to_document(name, &img)?; + let depth = if img.sample_type() == SampleType::U8 { "8-bit" } else { "16-bit" }; + r.warnings.insert( + 0, + format!( + "{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)" + ), + ); + Ok(r) + } Err(e) => Err(IoError::Raw(e)), } } +/// A decoded JPEG XL preview as an image: in its stated colour space (sRGB when it states +/// none), turned upright by the raw's orientation (DNG previews are stored as the raw is). +fn jxl_preview_image(raw: &[u8], p: photocraft_raw::DecodedPreview) -> Result { + let img = if p.bits == 8 { + Image::from_u8(p.width, p.height, ChannelLayout::Rgb, p.rgb.iter().map(|&v| v.min(255) as u8).collect())? + } else { + let max = f64::from((1u32 << p.bits.clamp(1, 16)) - 1); + let rgb: Vec = p.rgb.iter().map(|&v| (f64::from(v).min(max) * 65535.0 / max).round() as u16).collect(); + Image::from_u16(p.width, p.height, ChannelLayout::Rgb, &rgb)? + }; + let icc = match p.color_space { + Some(PreviewColorSpace::AdobeRgb) => Some(Builtin::AdobeRgbCompat.profile().to_bytes().to_vec()), + Some(PreviewColorSpace::ProPhoto) => Some(Builtin::ProPhotoCompat.profile().to_bytes().to_vec()), + Some(PreviewColorSpace::Srgb) | None => None, + }; + Ok(img.with_icc(icc).oriented(photocraft_raw::orientation(raw).unwrap_or(1))?) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn the_developed_note_says_which_demosaic_method_was_used() { + let note = format!("NEF: {DEVELOPED_NOTE} (mhc demosaic, as-shot white balance) into 16-bit ProPhoto RGB"); + assert_eq!(developed_demosaic(&["unrelated".to_string(), note]), Some(Demosaic::Mhc)); + assert_eq!(developed_demosaic(&[format!("{DEVELOPED_NOTE} (amaze demosaic)")]), None); + assert_eq!(developed_demosaic(&["opened the embedded preview".to_string()]), None); + } + #[test] fn camera_names_drop_a_repeated_make() { assert_eq!(camera_name(Some("Canon"), Some("Canon EOS 80D")), "Canon EOS 80D"); diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 78b2d79201..fcc9eec3f9 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -146,11 +146,12 @@ struct Known { command: &'static str, } +/// A four-character PSD code as the big-endian integer Photoshop stores it as. const fn code(c: &[u8; 4]) -> i32 { i32::from_be_bytes(*c) } -const KNOWN: [Known; 45] = [ +const KNOWN: [Known; 53] = [ Known { class: "GsnB", filter_id: code(b"GsnB"), name: "Gaussian Blur...", command: "filter.blur.gaussianBlur" }, Known { class: "boxblur", filter_id: 840, name: "Box Blur...", command: "filter.blur.boxBlur" }, Known { class: "MtnB", filter_id: code(b"MtnB"), name: "Motion Blur...", command: "filter.blur.motionBlur" }, @@ -198,6 +199,17 @@ const KNOWN: [Known; 45] = [ // Smart Sharpen has a numeric filterID and a stringID class, like Box Blur. Known { class: SMART_SHARPEN, filter_id: 1131, name: "Smart Sharpen...", command: "filter.sharpen.smartSharpen" }, Known { class: "DstS", filter_id: code(b"DstS"), name: "Dust & Scratches...", command: "filter.noise.dustAndScratches" }, + // Distort (Photoshop 27, `corpus`-style check: one smart object per filter saved from Photoshop + // and read back). Shear isn't mapped yet: its curve lives on Photoshop's own grid. + Known { class: "Pnch", filter_id: code(b"Pnch"), name: "Pinch...", command: "filter.distort.pinch" }, + Known { class: "Plr ", filter_id: code(b"Plr "), name: "Polar Coordinates...", command: "filter.distort.polarCoordinates" }, + Known { class: "Rple", filter_id: code(b"Rple"), name: "Ripple...", command: "filter.distort.ripple" }, + Known { class: "Sphr", filter_id: code(b"Sphr"), name: "Spherize...", command: "filter.distort.spherize" }, + Known { class: "Twrl", filter_id: code(b"Twrl"), name: "Twirl...", command: "filter.distort.twirl" }, + Known { class: "Wave", filter_id: code(b"Wave"), name: "Wave...", command: "filter.distort.wave" }, + Known { class: "ZgZg", filter_id: code(b"ZgZg"), name: "ZigZag...", command: "filter.distort.zigZag" }, + // Image › Adjustments › Invert on a smart object: no settings, so no `Fltr` (`hasoptions` false). + Known { class: "Invr", filter_id: code(b"Invr"), name: "Invert", command: "image.adjustments.invert" }, Known { class: "LnsF", filter_id: code(b"LnsF"), name: "Lens Flare...", command: "filter.render.lensFlare" }, // These three also read the item's foreground and background colours (`coloured_params`). Known { class: "Fbrs", filter_id: code(b"Fbrs"), name: "Fibers...", command: "filter.render.fibers" }, @@ -262,6 +274,15 @@ fn smart_sharpen_params(f: &Descriptor) -> Option { Some(J::Object(out)) } +/// Photoshop enum codes ↔ PhotoCraft parameter values, per filter setting. +const RIPPLE_SIZE: [(&[u8; 4], &str); 3] = [(b"Sml ", "small"), (b"Mdm ", "medium"), (b"Lrg ", "large")]; +const SPHERIZE_MODE: [(&[u8; 4], &str); 3] = [(b"Nrml", "normal"), (b"HrzO", "horizontalOnly"), (b"VrtO", "verticalOnly")]; +const POLAR_MODE: [(&[u8; 4], &str); 2] = [(b"RctP", "rectangularToPolar"), (b"PlrR", "polarToRectangular")]; +const ZIGZAG_STYLE: [(&[u8; 4], &str); 3] = [(b"PndR", "pondRipples"), (b"OtFr", "outFromCenter"), (b"ArnC", "aroundCenter")]; +const WAVE_TYPE: [(&[u8; 4], &str); 3] = [(b"WvSn", "sine"), (b"WvTr", "triangle"), (b"WvSq", "square")]; +const UNDEFINED_AREAS: [(&[u8; 4], &str); 2] = [(b"WrpA", "wrap"), (b"RptE", "repeat")]; + +/// The PSD filter record that PhotoCraft's `command` is written as, if it has one. fn known_by_command(command: &str) -> Option<&'static Known> { KNOWN.iter().find(|k| k.command == command) } @@ -295,10 +316,12 @@ fn color_halftone_params(f: &Descriptor) -> Option { Some(json!({"maxRadius": radius, "channel1": angle("Ang1")?, "channel2": angle("Ang2")?, "channel3": angle("Ang3")?, "channel4": angle("Ang4")?})) } +/// A numeric descriptor item as a finite number; `None` when it is absent, not a number, or not finite. fn px(d: &Descriptor, key: &str) -> Option { num(d.get(key)).filter(|v| v.is_finite()) } +/// An integer descriptor item; `None` when it is absent or of another type. fn int(d: &Descriptor, key: &str) -> Option { match d.get(key)? { Value::Integer(i) => Some(*i), @@ -547,15 +570,49 @@ fn custom_params(f: &Descriptor) -> Option { Some(json!({"kernel": kernel?, "scale": scale, "offset": offset})) } +/// The settings of Photoshop's Wave, as it writes them (and we write them back). +const WAVE_KEYS: [&str; 10] = ["Wvtp", "NmbG", "WLMn", "WLMx", "AmMn", "AmMx", "SclH", "SclV", "UndA", "RndS"]; + /// Our command and parameters for a known Photoshop filter; `None` when the filter (or one of its /// settings) is not modelled. `item` is the whole `filterFX` entry (Pointillize reads its `BckC`, /// Tiles its colours). fn known_params(class: &str, f: Option<&Descriptor>, item: &Descriptor) -> Option<(&'static str, J)> { let k = KNOWN.iter().find(|k| k.class == class)?; let params = match (class, f) { - ("adaptCorrect", None) => json!({}), + ("adaptCorrect" | "Invr", None) => json!({}), (c, None) if NO_OPTIONS.contains(&c) => json!({}), (_, None) => return None, + // Exactly the settings Photoshop writes (and we write back); an extra or repeated one keeps + // the filter verbatim rather than lose it on save. + ("Pnch", Some(f)) if exact_keys(f, &["Amnt"]) => json!({"amount": int(f, "Amnt")?}), + ("Twrl", Some(f)) if exact_keys(f, &["Angl"]) => json!({"angle": int(f, "Angl")?}), + ("Rple", Some(f)) if exact_keys(f, &["Amnt", "RplS"]) => json!({"amount": int(f, "Amnt")?, "size": enum_name(f, "RplS", "RplS", &RIPPLE_SIZE)?}), + ("Sphr", Some(f)) if exact_keys(f, &["Amnt", "SphM"]) => json!({"amount": int(f, "Amnt")?, "mode": enum_name(f, "SphM", "SphM", &SPHERIZE_MODE)?}), + ("Plr ", Some(f)) if exact_keys(f, &["Cnvr"]) => json!({"mode": enum_name(f, "Cnvr", "Cnvr", &POLAR_MODE)?}), + ("ZgZg", Some(f)) if exact_keys(f, &["Amnt", "NmbR", "ZZTy"]) => { + json!({"amount": int(f, "Amnt")?, "ridges": int(f, "NmbR")?, "style": enum_name(f, "ZZTy", "ZZTy", &ZIGZAG_STYLE)?}) + } + ("Wave", Some(f)) if exact_keys(f, &WAVE_KEYS) => { + // PhotoCraft's Wave has no Scale: a scaled wave stays verbatim rather than lose it. The + // same for more generators than the renderer models, which would draw fewer than + // Photoshop did (and than the file asks for, once its cached pixels are re-rendered). + let generators = int(f, "NmbG")?; + let modelled = u32::try_from(generators).is_ok_and(|g| (1..=photocraft_algo::MAX_WAVE_GENERATORS).contains(&g)); + if int(f, "SclH")? != 100 || int(f, "SclV")? != 100 || !modelled { + return None; + } + json!({ + "type": enum_name(f, "Wvtp", "Wvtp", &WAVE_TYPE)?, + "generators": generators, + "wavelengthMin": int(f, "WLMn")?, + "wavelengthMax": int(f, "WLMx")?, + "amplitudeMin": int(f, "AmMn")?, + "amplitudeMax": int(f, "AmMx")?, + "undefinedAreas": enum_name(f, "UndA", "UndA", &UNDEFINED_AREAS)?, + // `RndS` is a signed long; the seed is its bit pattern. + "seed": (int(f, "RndS")? as u32), + }) + } ("GsnB" | "boxblur" | "Mdn " | "HghP" | "Mxm " | "Mnm ", Some(f)) => json!({"radius": px(f, "Rds ")?}), ("MtnB", Some(f)) => json!({"angle": int(f, "Angl")?, "distance": px(f, "Dstn")?}), ("UnsM", Some(f)) => json!({"amount": px(f, "Amnt")?, "radius": px(f, "Rds ")?, "threshold": int(f, "Thsh")?}), @@ -1120,6 +1177,37 @@ fn fltr_for(k: &Known, p: &J) -> Result, String> { // No settings to write; any parameter keys an old record carries have no effect on the // filter either, so nothing is lost. c if NO_OPTIONS.contains(&c) => return Ok(None), + // Keys and order as Photoshop writes them. + "Pnch" => d.with("Amnt", pi(p, "amount", 50.0)), + "Twrl" => d.with("Angl", pi(p, "angle", 50.0)), + "Rple" => d.with("Amnt", pi(p, "amount", 100.0)).with("RplS", enum_param(p, "size", "medium", "RplS", &RIPPLE_SIZE)?), + "Sphr" => d.with("Amnt", pi(p, "amount", 100.0)).with("SphM", enum_param(p, "mode", "normal", "SphM", &SPHERIZE_MODE)?), + "Plr " => d.with("Cnvr", enum_param(p, "mode", "rectangularToPolar", "Cnvr", &POLAR_MODE)?), + "ZgZg" => { + d.with("Amnt", pi(p, "amount", 10.0)).with("NmbR", pi(p, "ridges", 5.0)).with("ZZTy", enum_param(p, "style", "pondRipples", "ZZTy", &ZIGZAG_STYLE)?) + } + "Wave" => { + // The seed the renderer draws with (engine `filters::seed`): a u32, else a legacy signed + // or fractional seed, rounded and saturated, as its bit pattern. + let seed = match p.get("seed").and_then(J::as_u64).and_then(|s| u32::try_from(s).ok()) { + Some(s) => Value::Integer(s as i32), + None => pi(p, "seed", 0.0), + }; + // The renderer draws at most MAX_WAVE_GENERATORS: write what PhotoCraft rendered, so + // Photoshop doesn't draw more generators than the user saw. + let generators = pf(p, "generators", 5.0).round().clamp(1.0, f64::from(photocraft_algo::MAX_WAVE_GENERATORS)) as i32; + d.with("Wvtp", enum_param(p, "type", "sine", "Wvtp", &WAVE_TYPE)?) + .with("NmbG", Value::Integer(generators)) + .with("WLMn", pi(p, "wavelengthMin", 10.0)) + .with("WLMx", pi(p, "wavelengthMax", 120.0)) + .with("AmMn", pi(p, "amplitudeMin", 5.0)) + .with("AmMx", pi(p, "amplitudeMax", 35.0)) + .with("SclH", Value::Integer(100)) + .with("SclV", Value::Integer(100)) + .with("UndA", enum_param(p, "undefinedAreas", "wrap", "UndA", &UNDEFINED_AREAS)?) + .with("RndS", seed) + } + "Invr" => return Ok(None), _ => return Err("not a recognized filter".into()), })) } @@ -1248,8 +1336,8 @@ pub struct PlacedSpec<'a> { /// Where the frame corners land (top-left, top-right, bottom-right, bottom-left), as x, y pairs. /// The frame is [`crate::blocks::placed_frame`] of `warp`, the warp the block stores, for an -/// affine placement (the inverse of `parse_smart`), the source rect for a projective one (as -/// `parse_smart_perspective` reads it). +/// affine placement (the inverse of `parse_smart`), [`crate::blocks::perspective_frame`] of it +/// for a projective one (as `parse_smart_perspective` reads it). fn quad_points(s: &PlacedSpec<'_>, warp: Option<&Warp>) -> [f64; 8] { let (w, h) = s.size; let [a, b, c, d, e, f] = s.transform.m; @@ -1257,7 +1345,7 @@ fn quad_points(s: &PlacedSpec<'_>, warp: Option<&Warp>) -> [f64; 8] { Some(p) => photocraft_algo::transform::Homography(*p).apply(x, y), None => (a * x + c * y + e, b * x + d * y + f), }; - let [x0, y0, x1, y1] = if s.perspective.is_some() { [0.0, 0.0, w, h] } else { crate::blocks::placed_frame(warp, w, h) }; + let [x0, y0, x1, y1] = if s.perspective.is_some() { crate::blocks::perspective_frame(warp, w, h) } else { crate::blocks::placed_frame(warp, w, h) }; let mut out = [0.0; 8]; for (i, (x, y)) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].into_iter().enumerate() { (out[2 * i], out[2 * i + 1]) = map(x, y); @@ -1308,10 +1396,10 @@ fn warp_head(class: &str, w: &Warp) -> Descriptor { .with("Left", Value::Double(x0)) .with("Btom", Value::Double(y1)) .with("Rght", Value::Double(x1)); - Descriptor::new(class) - .with("warpStyle", enumv("warpStyle", w.style.psd_name())) - .with("warpValue", Value::Double(w.bend)) - .with("warpPerspective", Value::Double(w.h_distort)) + let d = Descriptor::new(class).with("warpStyle", enumv("warpStyle", w.style.psd_name())).with("warpValue", Value::Double(w.bend)); + // A cylinder is stored as its seven `warpValues` (#4112). + let d = if w.style == WarpStyle::Cylinder { d.with("warpValues", Value::List(w.values.iter().copied().map(Value::Double).collect())) } else { d }; + d.with("warpPerspective", Value::Double(w.h_distort)) .with("warpPerspectiveOther", Value::Double(w.v_distort)) .with("warpRotate", enumv("Ornt", if w.vertical { "Vrtc" } else { "Hrzn" })) .with("bounds", Value::Descriptor(bounds)) @@ -1329,7 +1417,8 @@ fn mesh_points(m: &BezierMesh) -> Value { /// The `warp` descriptor (also the `PlLd` warp). A split warp is written as no warp: `SoLd` /// keeps it in `quiltWarp` ([`quilt_desc`]), next to this `warpNone`, as Photoshop does. A -/// custom warp without a usable mesh is written as no warp, with a message. +/// custom warp without a usable mesh is written as no warp, with a message. A cylinder is +/// written as its seven `warpValues`. fn warp_desc(w: Option<&Warp>, size: (f64, f64), warnings: &mut Vec) -> Value { let none = Warp::none([0.0, 0.0, size.0, size.1]); let mut w = w.unwrap_or(&none).clone(); @@ -1731,8 +1820,27 @@ mod tests { sf("image.adjustments.levels", json!({"inBlack": 20, "inWhite": 220, "gamma": 1.4, "red": {"inBlack": 0, "inWhite": 255, "gamma": 0.8}})), sf("image.adjustments.curves", json!({"points": [[0.0, 0.0], [64.0, 40.0], [255.0, 255.0]], "blue": [[0.0, 20.0], [255.0, 235.0]]})), sf("image.adjustments.shadowsHighlights", json!({})), + sf("filter.sharpen.sharpen", json!({})), + // Photoshop stores Dust & Scratches' radius in whole pixels (a `long`). + sf("filter.noise.dustAndScratches", json!({"radius": 4, "threshold": 12})), + sf( + "image.adjustments.hueSaturation", + json!({"colorize": false, "hue": 10, "saturation": -20, "lightness": 5, "reds": {"hue": 5, "saturation": 0, "lightness": -3, "range": [315, 345, 15, 45]}}), + ), // A pencil-drawn curve: 256 output levels (`Mpng`). sf("image.adjustments.curves", json!({"pencil": true, "points": (0..256).rev().collect::>()})), + sf("filter.distort.pinch", json!({"amount": -28})), + sf("filter.distort.polarCoordinates", json!({"mode": "rectangularToPolar"})), + sf("filter.distort.ripple", json!({"amount": 250, "size": "small"})), + sf("filter.distort.spherize", json!({"amount": 60, "mode": "verticalOnly"})), + sf("filter.distort.twirl", json!({"angle": -355})), + sf( + "filter.distort.wave", + json!({"type": "square", "generators": 3, "wavelengthMin": 20, "wavelengthMax": 90, "amplitudeMin": 4, "amplitudeMax": 30, + "undefinedAreas": "repeat", "seed": 4_000_000_000u32}), + ), + sf("filter.distort.zigZag", json!({"amount": 30, "ridges": 7, "style": "aroundCenter"})), + sf("image.adjustments.invert", json!({})), ]; for mut f in filters.into_iter().chain(wind_extrude_tiles_settings()) { f.blend = BlendMode::Overlay; @@ -2101,6 +2209,48 @@ mod tests { .with("filterID", Value::Integer(id)) } + /// The `Fltr` descriptors Photoshop 27 wrote for these filters (one smart object each, saved as + /// PSD and read back), with the settings PhotoCraft reads from them. + const PHOTOSHOP_FLTR: [(&str, &str, &str); 6] = [ + ("filter.distort.pinch", r#"{"amount":-28}"#, "0000000600500069006e00630068000000000000506e63680000000100000000416d6e746c6f6e67ffffffe4"), + ("filter.distort.twirl", r#"{"angle":-355}"#, "000000060054007700690072006c0000000000005477726c0000000100000000416e676c6c6f6e67fffffe9d"), + ( + "filter.distort.ripple", + r#"{"amount":-273,"size":"large"}"#, + "000000070052006900700070006c006500000000000052706c650000000200000000416d6e746c6f6e67fffffeef0000000052706c53656e756d0000000052706c53000000004c726720", + ), + ( + "filter.distort.spherize", + r#"{"amount":-1,"mode":"horizontalOnly"}"#, + "00000009005300700068006500720069007a0065000000000000537068720000000200000000416d6e746c6f6e67ffffffff000000005370684d656e756d000000005370684d0000000048727a4f", + ), + ( + "filter.distort.polarCoordinates", + r#"{"mode":"polarToRectangular"}"#, + "000000120050006f006c0061007200200043006f006f007200640069006e0061007400650073000000000000506c72200000000100000000436e7672656e756d00000000436e767200000000506c7252", + ), + ( + "filter.distort.zigZag", + r#"{"amount":-42,"ridges":11,"style":"pondRipples"}"#, + "00000007005a00690067005a006100670000000000005a675a670000000300000000416d6e746c6f6e67ffffffd6000000004e6d62526c6f6e670000000b000000005a5a5479656e756d000000005a5a547900000000506e6452", + ), + ]; + + /// Photoshop's Wave with Scale 100% / 28%: PhotoCraft's Wave has no Scale. + const PHOTOSHOP_WAVE: &str = "000000050057006100760065000000000000576176650000000a0000000057767470656e756d00000000577674700000000057765472000000004e6d62476c6f6e670000014c00000000574c4d6e6c6f6e67000000d500000000574c4d786c6f6e67000000d600000000416d4d6e6c6f6e67000000f000000000416d4d786c6f6e67000000f10000000053636c486c6f6e67000000640000000053636c566c6f6e670000001c00000000556e6441656e756d00000000556e6441000000005772704100000000526e64536c6f6e670013624a"; + + /// A `filterFX` list item as Photoshop writes it (enabled, Normal at 100%, with options), wrapping the `Fltr` descriptor `fltr`. + fn photoshop_fltr_item(fltr: Descriptor) -> Descriptor { + let id = code(fltr.class_id.as_bytes().try_into().unwrap()); + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("from Photoshop"))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(id)) + } + fn wind(method: &str, direction: &str) -> Descriptor { Descriptor::new("Wnd ").with("WndM", enumv("WndM", method)).with("Drct", enumv("Drct", direction)) } @@ -2580,17 +2730,233 @@ mod tests { assert_eq!(filter_from_item(&bare).command, UNSUPPORTED_FILTER); } + /// Distort smart filters from a Photoshop file open as their PhotoCraft filters, and the ones + /// PhotoCraft writes carry the same settings, keys and order as Photoshop's. + #[test] + fn photoshop_distort_filters_read_and_write_like_photoshop() { + for (command, params, hex) in PHOTOSHOP_FLTR { + let ps = Descriptor::from_bytes(&from_hex(hex).unwrap()).unwrap(); + let f = filter_from_item(&photoshop_fltr_item(ps.clone())); + let want: J = serde_json::from_str(params).unwrap(); + assert_eq!((f.command.as_str(), &f.params), (command, &want), "{command}"); + let ours = match item_for_filter(&f).unwrap().get("Fltr") { + Some(Value::Descriptor(d)) => d.clone(), + other => panic!("{command}: no Fltr ({other:?})"), + }; + assert_eq!((&ours.class_id, &ours.items), (&ps.class_id, &ps.items), "{command}: same keys, values and order"); + } + // A scaled wave can't be modelled, so it stays Photoshop's, unchanged. + let wave = Descriptor::from_bytes(&from_hex(PHOTOSHOP_WAVE).unwrap()).unwrap(); + let item = photoshop_fltr_item(wave); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap().get("Fltr"), item.get("Fltr")); + // Unscaled, but with 332 generators (the renderer draws 64 at most), it stays Photoshop's too. + let mut unscaled = match item.get("Fltr") { + Some(Value::Descriptor(d)) => d.clone(), + other => panic!("wave: no Fltr ({other:?})"), + }; + set(&mut unscaled, "SclV", Value::Integer(100)); + let item = photoshop_fltr_item(unscaled); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap().get("Fltr"), item.get("Fltr")); + + // An extra or repeated setting PhotoCraft doesn't model keeps the filter Photoshop's, unchanged. + let mut fltrs: Vec = PHOTOSHOP_FLTR.iter().map(|(_, _, hex)| Descriptor::from_bytes(&from_hex(hex).unwrap()).unwrap()).collect(); + let mut wave = unscaled_wave(); + set(&mut wave, "NmbG", Value::Integer(5)); + assert_eq!(filter_from_item(&photoshop_fltr_item(wave.clone())).command, "filter.distort.wave", "the plain wave maps"); + fltrs.push(wave); + for ps in fltrs { + let name = String::from_utf8_lossy(ps.class_id.as_bytes()).into_owned(); + let mut repeated = ps.clone(); + repeated.items.push(ps.items[0].clone()); + for odd in [ps.clone().with("Xtra", Value::Integer(7)), repeated] { + let item = photoshop_fltr_item(odd); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER, "{name}"); + assert_eq!(item_for_filter(&f).unwrap().get("Fltr"), item.get("Fltr"), "{name}: written back unchanged"); + } + } + } + + /// Photoshop's Wave ([`PHOTOSHOP_WAVE`]) at 100% scale both ways. + fn unscaled_wave() -> Descriptor { + let mut wave = Descriptor::from_bytes(&from_hex(PHOTOSHOP_WAVE).unwrap()).unwrap(); + set(&mut wave, "SclV", Value::Integer(100)); + wave + } + + /// An unscaled Wave opens as PhotoCraft's Wave (Triangle, the other settings as stored) up to + /// the generators the renderer models; one more and it stays Photoshop's, unchanged. + #[test] + fn wave_maps_up_to_the_generators_the_renderer_models() { + let max = i32::try_from(photocraft_algo::MAX_WAVE_GENERATORS).unwrap(); + assert_eq!(max, 64); + let mut wave = Descriptor::from_bytes(&from_hex(PHOTOSHOP_WAVE).unwrap()).unwrap(); + set(&mut wave, "SclV", Value::Integer(100)); + for (generators, mapped) in [(1, true), (max, true), (max + 1, false), (999, false), (0, false), (-1, false)] { + set(&mut wave, "NmbG", Value::Integer(generators)); + let item = photoshop_fltr_item(wave.clone()); + let f = filter_from_item(&item); + if mapped { + assert_eq!( + (f.command.as_str(), &f.params), + ( + "filter.distort.wave", + &json!({"type": "triangle", "generators": generators, "wavelengthMin": 213, "wavelengthMax": 214, "amplitudeMin": 240, + "amplitudeMax": 241, "undefinedAreas": "wrap", "seed": 1270346}) + ), + "{generators} generators" + ); + match item_for_filter(&f).unwrap().get("Fltr") { + Some(Value::Descriptor(ours)) => assert_eq!((&ours.class_id, &ours.items), (&wave.class_id, &wave.items), "{generators} generators"), + other => panic!("wave: no Fltr ({other:?})"), + } + } else { + assert_eq!(f.command, UNSUPPORTED_FILTER, "{generators} generators"); + assert_eq!(item_for_filter(&f).unwrap().get("Fltr"), item.get("Fltr"), "{generators} generators"); + } + } + } + + /// PhotoCraft draws at most `MAX_WAVE_GENERATORS` generators, so a Wave edited past that exports + /// the 64 it rendered, and a count below 1 exports 1, instead of Photoshop drawing more. + #[test] + fn wave_export_writes_only_the_generators_the_renderer_draws() { + let max = i32::try_from(photocraft_algo::MAX_WAVE_GENERATORS).unwrap(); + for (stored, written) in [(100, max), (999, max), (max + 1, max), (max, max), (5, 5), (1, 1), (0, 1), (-3, 1)] { + let wave = sf( + "filter.distort.wave", + json!({"type": "sine", "generators": stored, "wavelengthMin": 20, "wavelengthMax": 90, "amplitudeMin": 4, "amplitudeMax": 30}), + ); + let fltr = match item_for_filter(&wave).unwrap().get("Fltr") { + Some(Value::Descriptor(d)) => d.clone(), + other => panic!("wave: no Fltr ({other:?})"), + }; + assert_eq!(int(&fltr, "NmbG"), Some(written), "{stored} generators"); + } + } + + /// `RndS` is the seed the renderer draws with, also for a legacy signed or fractional seed + /// (stored as is by `layer.smartFilter.setParams`), not 0. + #[test] + fn wave_export_writes_the_seed_the_renderer_draws() { + for (stored, written) in [ + (json!(77), 77), + (json!(4_000_000_000u32), -294_967_296), + (json!(-1), -1), + (json!(7.4), 7), + (json!(-2.6), -3), + (json!(1e12), i32::MAX), + (json!("x"), 0), + ] { + let wave = sf("filter.distort.wave", json!({"generators": 3, "seed": stored})); + let fltr = match item_for_filter(&wave).unwrap().get("Fltr") { + Some(Value::Descriptor(d)) => d.clone(), + other => panic!("wave: no Fltr ({other:?})"), + }; + assert_eq!(int(&fltr, "RndS"), Some(written), "seed {stored}"); + // And it reads back as that bit pattern. + assert_eq!(known_params("Wave", Some(&fltr), &Descriptor::new("filterFX")).unwrap().1["seed"], json!(written as u32), "seed {stored}"); + } + } + + /// Invert on a smart object: Photoshop stores no `Fltr`, `hasoptions` false and its filter id. + #[test] + fn invert_smart_filter_reads_and_writes_like_photoshop() { + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Invert"))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(false)) + .with("FrgC", rgbc(255.0)) + .with("BckC", rgbc(0.0)) + .with("filterID", Value::Integer(code(b"Invr"))); + let f = filter_from_item(&item); + assert_eq!((f.command.as_str(), &f.params), ("image.adjustments.invert", &json!({}))); + let back = item_for_filter(&f).unwrap(); + assert_eq!(back.get("Fltr"), None); + assert_eq!(back.get("hasoptions"), Some(&Value::Boolean(false))); + assert_eq!(back.get("filterID"), Some(&Value::Integer(code(b"Invr")))); + assert_eq!(back.get("Nm "), Some(&Value::Text(UnicodeString::new_nul("Invert")))); + } + + /// A filter PhotoCraft doesn't model, or a known one with settings it doesn't, is kept verbatim; only its visibility and blend change. + #[test] + fn photoshop_sharpen_without_fltr_imports_as_sharpen() { + // #6267: Photoshop's Sharpen item holds only its name, blending and `filterID`. + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Sharpen"))) + .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(false)) + .with("filterID", Value::Integer(1_399_353_968)); + let f = filter_from_item(&item); + assert_eq!((f.command.as_str(), &f.params), ("filter.sharpen.sharpen", &json!({}))); + // Hue/Saturation with an unnumbered range and Dust & Scratches with a missing setting fail + // cleanly instead of panicking. + let bad = SmartFilter { + command: "image.adjustments.hueSaturation".into(), + params: json!({"reds": {"range": [1, "x", 3, 4]}}), + blend: BlendMode::Normal, + opacity: 1.0, + visible: true, + }; + assert!(item_for_filter(&bad).is_err()); + let dust = Descriptor::new("filterFX") + .with("Fltr", Value::Descriptor(Descriptor::new("DstS").with("Rds ", Value::Integer(3)))) + .with("filterID", Value::Integer(code(b"DstS"))); + assert_eq!(filter_from_item(&dust).command, UNSUPPORTED_FILTER); + } + + #[test] + fn hue_saturation_ranges_are_not_lost() { + let hue_sat = |params: J| item_for_filter(&sf("image.adjustments.hueSaturation", params)); + // A range that is not an object is an error, not a silently dropped setting. + assert!(hue_sat(json!({"reds": 5})).is_err()); + assert!(hue_sat(json!({"reds": null})).is_ok()); + // Colorize's 25% default is the master saturation's only: a range's stays 0. + let d = hue_sat(json!({"colorize": true, "reds": {"hue": 5}})).unwrap(); + let adjs = adjustment_list(get_desc(&d, "Fltr").unwrap()).unwrap(); + let saturations: Vec<_> = adjs.iter().map(|a| int(a, "Strt")).collect(); + assert_eq!(saturations, [Some(25), Some(0)]); + // A repeated range (or master) would overwrite the first: the filter is kept verbatim. + let entry = |range: Option| { + let e = Descriptor::new("Hst2").with("H ", Value::Integer(0)).with("Strt", Value::Integer(0)).with("Lght", Value::Integer(0)); + let Some(r) = range else { return Value::Descriptor(e) }; + let e = ["BgnR", "BgnS", "EndS", "EndR"].iter().fold(e.with("LclR", Value::Integer(r)), |e, k| e.with(k, Value::Integer(10))); + Value::Descriptor(e) + }; + for adjs in [vec![entry(None), entry(Some(1)), entry(Some(1))], vec![entry(None), entry(None)]] { + let fltr = Descriptor::new("HStr").with("Clrz", Value::Boolean(false)).with("Adjs", Value::List(adjs)); + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Hue/Saturation..."))) + .with( + "blendOptions", + Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal"))), + ) + .with("enab", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(code(b"HStr"))); + let f = filter_from_item(&item); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), item); + } + } + #[test] fn unknown_filters_keep_their_descriptor() { let item = Descriptor::new("filterFX") - .with("Nm ", Value::Text(UnicodeString::new_nul("Twirl..."))) + .with("Nm ", Value::Text(UnicodeString::new_nul("Shear..."))) .with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions").with("Opct", unit(b"#Prc", 100.0)).with("Md ", enumv("BlnM", "normal")))) .with("enab", Value::Boolean(true)) - .with("Fltr", Value::Descriptor(Descriptor::new("Twrl").with("Angl", Value::Integer(50)))) - .with("filterID", Value::Integer(code(b"Twrl"))); + .with("Fltr", Value::Descriptor(Descriptor::new("Shr ").with("UndA", enumv("UndA", "WrpA")))) + .with("filterID", Value::Integer(code(b"Shr "))); let f = filter_from_item(&item); assert_eq!(f.command, UNSUPPORTED_FILTER); - assert_eq!(f.params["name"], "Twirl..."); + assert_eq!(f.params["name"], "Shear..."); assert_eq!(item_for_filter(&f).unwrap(), item); // Hidden and re-blended: only those keys change. let mut g = f.clone(); @@ -3093,11 +3459,13 @@ mod tests { } } + /// Filters with no PSD equivalent are skipped with a warning each, and the writable ones around them still export. #[test] fn unwritable_filters_are_reported_not_fatal() { let stack = FilterStack { filters: vec![ - sf("filter.distort.twirl", json!({"angle": 50})), + // Shear's curve isn't mapped to Photoshop's grid yet. + sf("filter.distort.shear", json!({"amount": 40})), sf("filter.blur.gaussianBlur", json!({"radius": 2.0})), sf(UNSUPPORTED_FILTER, json!({"psd": "zz"})), sf("image.adjustments.levels", json!({"cyan": {"gamma": 2.0}})), @@ -3506,6 +3874,53 @@ mod tests { assert!(!quad_moved(&p.descriptor, &spec, spec.warp)); } + /// #4112: a regenerated `SoLd` (after Replace Contents, a move…) writes a cylinder back as + /// `warpCylinder` and its seven values, not as no warp, and frames its control-point box with + /// the quad, affine or projective, so import places it where it was. + #[test] + fn cylinder_warp_round_trips_through_sold() { + let v = [100.0, 800.0, 900.0, 200.0, 0.3, 0.3, 0.5]; + let warp = Warp::cylinder(&v, [0.0, 0.0, 1000.0, 1000.0]).unwrap(); + let transform = Affine { m: [0.5, 0.0, 0.0, 0.5, 40.0, 30.0] }; + let mut spec = + PlacedSpec { idnt: "i", placed: "p", transform, perspective: None, size: (1000.0, 1000.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + let mut warnings = Vec::new(); + let sold = sold_bytes(None, &spec, &mut warnings); + assert!(warnings.is_empty(), "{warnings:?}"); + let p = parse_sold(&sold).expect("parses"); + let Some(Value::Descriptor(w)) = p.descriptor.get("warp") else { panic!("no warp") }; + assert_eq!(crate::blocks::enum_of(w, "warpStyle"), Some(b"warpCylinder".as_slice())); + let Some(Value::List(values)) = w.get("warpValues") else { panic!("no warpValues") }; + assert_eq!(values.iter().filter_map(|x| num(Some(x))).collect::>(), v); + assert!(w.get("customEnvelopeWarp").is_none()); + assert_eq!(crate::blocks::parse_placed_warp(b"SoLd", &sold), Some(warp.clone())); + for (a, b) in p.transform.m.iter().zip(transform.m) { + assert!((a - b).abs() < 1e-9, "{:?}", p.transform); + } + // The quad is the image of the control-point box, not of the source rect. + let cb = warp.mesh.as_ref().unwrap().control_bounds(); + let Some(Value::List(q)) = p.descriptor.get("Trnf") else { panic!("no Trnf") }; + let q: Vec = q.iter().filter_map(|x| num(Some(x))).collect(); + let want = [cb[0], cb[1], cb[2], cb[1], cb[2], cb[3], cb[0], cb[3]].map(|c| 0.5 * c); + for (i, (a, b)) in q.iter().zip(want).enumerate() { + let shift = if i % 2 == 0 { 40.0 } else { 30.0 }; + assert!((a - (b + shift)).abs() < 1e-9, "corner {i}: {q:?}"); + } + assert!(!quad_moved(&p.descriptor, &spec, spec.warp)); + // A projective placement comes back as the same map. + let h = photocraft_algo::transform::Homography::rect_to_quad(cb, [[10.0, 20.0], [410.0, 60.0], [400.0, 480.0], [30.0, 520.0]]).unwrap(); + spec.perspective = Some(h.0); + let sold = sold_bytes(None, &spec, &mut warnings); + let back = crate::blocks::parse_smart_perspective(b"SoLd", &sold).expect("projective"); + let n = |m: [f64; 9]| m.map(|x| x / m[8]); + for (a, b) in n(back).iter().zip(n(h.0)) { + assert!((a - b).abs() < 1e-9 * (1.0 + b.abs()), "{back:?} vs {:?}", h.0); + } + let plld = plld_bytes(&spec, &mut warnings); + assert_eq!(crate::blocks::parse_plld_warp(&plld), crate::blocks::PlldWarp::Warp(warp)); + assert!(warnings.is_empty(), "{warnings:?}"); + } + #[test] fn hex_and_uuid() { assert_eq!(from_hex(&to_hex(&[0, 1, 0xab, 0xff])), Some(vec![0, 1, 0xab, 0xff])); diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 85ccf1f184..312b2b8f03 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -28,6 +28,75 @@ use crate::{ImportResult, IoError}; pub const MAX_SIDE: u32 = 16_384; /// A rasterised fallback layer or a smart-object render never exceeds this many pixels. const MAX_RASTER_PIXELS: u64 = 24_000_000; +/// Largest decoded byte size (RGBA8) accepted for an embedded raster image before resvg decodes +/// it. resvg/tiny-skia allocate the full intrinsic image before any destination-size scaling, +/// so a highly compressed PNG or WebP could otherwise trigger gigabyte-scale allocation and +/// decompression that the destination pixmap's size check never sees. +const MAX_EMBEDDED_IMAGE_BYTES: u64 = MAX_RASTER_PIXELS * 4; + +/// The IHDR width/height of a PNG, read directly from its header (no decoding): the signature +/// (8 bytes), the IHDR chunk's length and type (8 bytes), then big-endian width and height. +fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> { + let sig = data.get(0..8)?; + if sig != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] { + return None; + } + if data.get(12..16)? != b"IHDR" { + return None; + } + let w = u32::from_be_bytes(data.get(16..20)?.try_into().ok()?); + let h = u32::from_be_bytes(data.get(20..24)?.try_into().ok()?); + Some((w, h)) +} + +/// Intrinsic dimensions of a raster image resvg can embed, from its header alone, for formats +/// where decoding allocates the full image before any destination scaling (PNG, WebP). +/// Returns `Some(Some((w, h)))` when the format is PNG/WebP and its header parses cleanly, +/// `Some(None)` when the format is PNG/WebP (by MIME or magic bytes under `"text/plain"`) +/// and its header is malformed, or `None` for other formats. +/// A JPEG's size from its frame header (the first SOF marker), without decoding anything. +fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> { + let mut i = 2; + loop { + if *data.get(i)? != 0xFF { + return None; + } + let marker = *data.get(i + 1)?; + match marker { + 0xFF => i += 1, + // SOF0..SOF15, but for DHT (C4), JPG (C8) and DAC (CC). + 0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) => { + let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]); + let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]); + return Some((u32::from(w), u32::from(h))); + } + // Start of scan or end of image before any frame header. + 0xD8..=0xDA => return None, + 0x01 | 0xD0..=0xD7 => i += 2, + _ => i += 2 + usize::from(u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?])), + } + } +} + +fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option> { + match mime { + "image/png" => Some(png_dimensions(data)), + "image/webp" | "image/gif" => Some(photocraft_codecs::probe_size(data).ok()), + "image/jpeg" | "image/jpg" => Some(jpeg_dimensions(data)), + "text/plain" => { + if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + Some(png_dimensions(data)) + } else if (data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP")) || data.starts_with(b"GIF8") { + Some(photocraft_codecs::probe_size(data).ok()) + } else if data.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some(jpeg_dimensions(data)) + } else { + None + } + } + _ => None, + } +} /// Aggregate memory for retained fallback surfaces in the document's pixel format. const MAX_PIXEL_BYTES: u64 = 1 << 30; /// Estimated peak during raster conversion (retained surfaces + RGBA8 pixmap + f32 staging + tiles). @@ -65,50 +134,192 @@ fn fonts() -> Arc { } #[cfg(not(target_arch = "wasm32"))] db.load_system_fonts(); - configure_generic_fonts(&mut db); + resolve_generic_families(&mut db, system_generic_family); Arc::new(db) }) .clone() } -/// fontdb's defaults name common Windows fonts. If those families aren't installed, -/// usvg's missing-family fallback resolves to nothing and drops otherwise valid SVG text. -/// Keep existing mappings; fill only missing generics with a family actually in this database. -fn configure_generic_fonts(db: &mut usvg::fontdb::Database) { - use usvg::fontdb::{Family, Query}; - for (generic, candidates) in [ - (Family::Serif, &["Noto Serif", "DejaVu Serif", "Liberation Serif", "Inter"][..]), - (Family::SansSerif, &["Noto Sans", "DejaVu Sans", "Liberation Sans", "Inter"][..]), - (Family::Monospace, &["Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "JetBrains Mono"][..]), +/// Installed families that stand in for each generic one, best first: the metric-compatible +/// Liberation and Croscore fonts, then common Linux defaults, then the bundled craft fonts. +const SANS_SERIF: &[&str] = &["Liberation Sans", "Arimo", "Helvetica", "DejaVu Sans", "Noto Sans", photocraft_text::fonts::DEFAULT_FAMILY]; +const SERIF: &[&str] = &["Liberation Serif", "Tinos", "Times", "DejaVu Serif", "Noto Serif"]; +const MONOSPACE: &[&str] = &["Liberation Mono", "Cousine", "Courier", "DejaVu Sans Mono", "Noto Sans Mono", photocraft_text::fonts::MONO_FAMILY]; + +/// Points the generic families (`sans-serif`, `serif`, `monospace`, `cursive`, `fantasy`) at +/// fonts the database holds. fontdb names Windows fonts for them (Arial, Times New Roman, +/// Courier New…): on a system without those, every family of a usual +/// `font-family="Arial, Helvetica, sans-serif"` misses, and usvg drops the text although +/// hundreds of fonts are installed, where a browser sets it in a fallback font. A generic whose +/// font is installed is left alone. Otherwise the system's choice (`system`, Fontconfig on Linux) +/// wins when the database has it, then the first installed stand-in, then the sans-serif choice, +/// then any font at all. +fn resolve_generic_families(db: &mut usvg::fontdb::Database, system: impl Fn(&str) -> Option) { + use usvg::fontdb::Family; + let has = |db: &usvg::fontdb::Database, name: &str| db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))); + let Some(any) = db.faces().find_map(|f| f.families.first()).map(|(n, _)| n.clone()) else { return }; + let mut sans = None; + for (generic, css, stand_ins) in [ + (Family::SansSerif, "sans-serif", SANS_SERIF), + (Family::Serif, "serif", SERIF), + (Family::Monospace, "monospace", MONOSPACE), + (Family::Cursive, "cursive", &[][..]), + (Family::Fantasy, "fantasy", &[][..]), ] { - if db.query(&Query { families: &[generic], ..Default::default() }).is_some() { - continue; + let current = db.family_name(&generic).to_string(); + let pick = if has(db, ¤t) { + current + } else { + system(css) + .filter(|n| has(db, n)) + .or_else(|| stand_ins.iter().find(|n| has(db, n)).map(|n| n.to_string())) + .or_else(|| sans.clone()) + .unwrap_or_else(|| any.clone()) + }; + match generic { + Family::SansSerif => db.set_sans_serif_family(pick.clone()), + Family::Serif => db.set_serif_family(pick.clone()), + Family::Monospace => db.set_monospace_family(pick.clone()), + Family::Cursive => db.set_cursive_family(pick.clone()), + _ => db.set_fantasy_family(pick.clone()), } - let family = candidates - .iter() - .find(|name| db.query(&Query { families: &[Family::Name(name)], ..Default::default() }).is_some()) - .map(|name| (*name).to_string()) - .or_else(|| db.faces().find_map(|face| face.families.first().map(|(name, _)| name.clone()))); - if let Some(family) = family { - match generic { - Family::Serif => db.set_serif_family(family), - Family::SansSerif => db.set_sans_serif_family(family), - Family::Monospace => db.set_monospace_family(family), - _ => {} - } + if generic == Family::SansSerif { + sans = Some(pick); } } } +/// The family Fontconfig sets a generic family in (`fc-match sans-serif`), as the rest of the +/// desktop does; `None` without Fontconfig. +#[cfg(all(target_os = "linux", not(target_arch = "wasm32")))] +fn system_generic_family(generic: &str) -> Option { + let out = std::process::Command::new("fc-match").args(["--format=%{family[0]}", generic]).output().ok()?; + let name = String::from_utf8(out.stdout).ok()?; + Some(name.trim().to_string()).filter(|n| out.status.success() && !n.is_empty()) +} + +#[cfg(not(all(target_os = "linux", not(target_arch = "wasm32"))))] +fn system_generic_family(_generic: &str) -> Option { + None +} + +/// Most font file bytes [`in_memory`] keeps; past it a face stays on disk (slow, still right). +#[cfg(not(target_arch = "wasm32"))] +const MAX_CACHED_FONT_BYTES: usize = 256 << 20; + +/// `id`, or the same face loaded from memory when the database names a file for it. fontdb +/// re-reads a system font's whole file for every glyph usvg outlines (about a millisecond a +/// character with Times New Roman), so a short entity-expanded text could keep an import busy +/// for minutes. Each file is read once per process; the parse's own database copy then holds +/// every face of that file from memory. +#[cfg(not(target_arch = "wasm32"))] +fn in_memory(id: usvg::fontdb::ID, db: &mut Arc) -> usvg::fontdb::ID { + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::{Mutex, PoisonError}; + use usvg::fontdb::Source; + type Files = (HashMap>>, usize); + static FILES: OnceLock> = OnceLock::new(); + static LOADING: Mutex<()> = Mutex::new(()); + let Some((Source::File(path), index)) = db.face_source(id) else { return id }; + let files = || FILES.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner); + let fits = |files: &Files, len: usize| files.1.saturating_add(len) <= MAX_CACHED_FONT_BYTES; + let cached = files().0.get(&path).cloned(); + // One miss at a time, so imports wanting the same file read it once: a second one waits + // here, then takes the bytes the first one kept. + let loading = cached.is_none().then(|| LOADING.lock().unwrap_or_else(PoisonError::into_inner)); + let cached = cached.or_else(|| files().0.get(&path).cloned()); + let bytes = match cached { + Some(b) => b, + None => { + // The size first: a file past the budget isn't read at all, on this lookup or the next. + let Ok(len) = std::fs::metadata(&path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)) else { return id }; + if !fits(&files(), len) { + return id; + } + let Ok(b) = std::fs::read(&path) else { return id }; + let b = Arc::new(b); + // The face has to load from the bytes before they're kept or the file's faces leave + // the database: a file replaced or cut short since fontdb indexed it stays on disk. + let mut probe = usvg::fontdb::Database::new(); + let ids = probe.load_font_source(Source::Binary(b.clone())); + if !ids.iter().any(|&n| probe.face_source(n).is_some_and(|(_, i)| i == index)) { + return id; + } + let mut files = files(); + if !fits(&files, b.len()) { + return id; + } + files.1 = files.1.saturating_add(b.len()); + files.0.insert(path.clone(), b.clone()); + b + } + }; + drop(loading); + let db = Arc::make_mut(db); + let same_file: Vec<_> = db.faces().filter(|f| matches!(&f.source, Source::File(p) if *p == path)).map(|f| f.id).collect(); + // Last first: fontdb's slots are reused last-freed-first, so the file's faces come back in + // their old places and order, and the fallback search (first face with the character) + // still finds the face it finds on disk. + for f in same_file.into_iter().rev() { + db.remove_face(f); + } + let loaded = db.load_font_source(Source::Binary(bytes)); + loaded.into_iter().find(|&n| db.face_source(n).is_some_and(|(_, i)| i == index)).unwrap_or(id) +} + +/// usvg's font choice, with the chosen faces read from memory ([`in_memory`]). +fn font_resolver() -> usvg::FontResolver<'static> { + #[cfg(not(target_arch = "wasm32"))] + { + let select = usvg::FontResolver::default_font_selector(); + let fallback = usvg::FontResolver::default_fallback_selector(); + usvg::FontResolver { + select_font: Box::new(move |font, db| select(font, db).map(|id| in_memory(id, db))), + select_fallback: Box::new(move |c, used, db| fallback(c, used, db).map(|id| in_memory(id, db))), + } + } + #[cfg(target_arch = "wasm32")] + usvg::FontResolver::default() +} + /// Parse options for an untrusted drawing. `` resolves only `data:` URLs: a file path /// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`, /// `~/.ssh/...`) into the document. Embedded SVG images get no `` resolution at all /// (`usvg` already blanks it for sub-SVGs). fn options(fontdb: Arc) -> usvg::Options<'static> { + let default_resolver = usvg::ImageHrefResolver::default_data_resolver(); + let resolve_data: usvg::ImageHrefDataResolverFn<'static> = Box::new(move |mime, data, opts| { + // Reject embedded images whose decoded RGBA storage would exceed the budget before + // resvg (which allocates the full intrinsic image before any destination scaling) ever + // sees them. Formats without a cheap header probe fall through unchanged; their decode + // path is bounded elsewhere or accepted as-is, matching the previous behavior. + if let Some(probed) = embedded_image_dimensions(mime, &data) { + let (w, h) = probed?; + let decoded = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4); + if decoded > MAX_EMBEDDED_IMAGE_BYTES { + return None; + } + } + // An embedded `image/svg+xml` value can itself be gzip-compressed (SVGZ-in-data-URL). + // The default resolver's sub-SVG loader checks for the gzip magic and decompresses + // again with no byte limit of its own. Apply our bounded inflate first: if the data is + // gzip-compressed, hand the default resolver the already-decompressed (and + // size-checked) bytes, which no longer start with the gzip magic, so it parses them + // directly instead of decompressing a second time. + if (mime == "image/svg+xml" || mime == "text/plain") && data.starts_with(&[0x1f, 0x8b]) { + return match inflate(&data) { + Ok(xml) if !xml.starts_with(&[0x1f, 0x8b]) => default_resolver("image/svg+xml", std::sync::Arc::new(xml.into_owned()), opts), + _ => None, + }; + } + default_resolver(mime, data, opts) + }); usvg::Options { resources_dir: None, - image_href_resolver: usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) }, + image_href_resolver: usvg::ImageHrefResolver { resolve_data, resolve_string: Box::new(|_, _| None) }, fontdb, + font_resolver: font_resolver(), ..usvg::Options::default() } } @@ -141,7 +352,11 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Resul let fontdb = if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) }; let fontless_text = has_text && fontdb.faces().next().is_none(); let opt = options(fontdb); - let tree = usvg::Tree::from_data(&xml, &opt).map_err(|e| IoError::Svg(e.to_string()))?; + // Parse the already-bounded-and-decoded text with from_str, not from_data: from_data + // re-checks the gzip magic and would decompress a second time without our size limit if + // the inflated content happened to start with a nested gzip stream. + 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).map_err(|e| IoError::Svg(e.to_string()))?; Ok(Parsed { tree, fontless_text }) } @@ -152,6 +367,9 @@ struct ImageBudget { pixels: u64, xml_bytes: u64, svg_images: u64, + draw_nodes: u64, + repeatable_raster: bool, + repeatable_svg: bool, fontless_text: bool, error: Option, } @@ -346,7 +564,69 @@ fn parse_image_bounded_with( if let Some(error) = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.clone() { return Err(IoError::Svg(error)); } - tree.map_err(|e| IoError::Svg(e.to_string())) + 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) { + 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, + decoded: u64, + draw_nodes: u64, + repeatable_raster: bool, + repeatable_svg: bool, } fn parse_bounded(bytes: &[u8], max_pixels: u64) -> Result { @@ -358,10 +638,18 @@ fn parse_bounded_inner(bytes: &[u8], max_pixels: u64) -> Result<(Parsed, u64), I } fn parse_bounded_inner_with(bytes: &[u8], max_pixels: u64, fonts: fn() -> Arc) -> Result<(Parsed, u64), IoError> { + parse_bounded_work_with(bytes, max_pixels, fonts).map(|work| (work.parsed, work.svg_images)) +} + +/// The decoder's charged pixels, including raster images inside nested SVG images. +fn parse_bounded_work_with(bytes: &[u8], max_pixels: u64, 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, + draw_nodes: 0, + repeatable_raster: false, + repeatable_svg: false, fontless_text: false, error: None, })); @@ -374,7 +662,15 @@ fn parse_bounded_inner_with(bytes: &[u8], max_pixels: u64, fonts: fn() -> Arc R 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. +pub fn import_svg_bounded_with_work(name: &str, bytes: &[u8], max_work: u64) -> 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), fonts)?; + let area = canvas(&parsed.tree, &mut Vec::new()).size.area(); + 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. @@ -656,18 +991,25 @@ 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, include_text: bool) -> usize { - let mut n = 0; + 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 include_text && let usvg::Node::Text(text) = node { stack.push(text.flattened()); } - if n > MAX_NODES { + if n > limit { return n; } } @@ -902,7 +1244,25 @@ impl Conv { _ => FillRule::NonZero, }; let mut sh = ShapeLayer { path: Path { subpaths, fill_rule, inverted: false }, fill, stroke, ..Default::default() }; - sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas)); + // The pixel cache is optional, so it is charged like any other retained raster and left + // out (the shape renders from its path) once the budget is spent: compact geometry can + // cover the whole canvas in every one of thousands of shapes. + let b = data.bounds(); + let reach = sh.stroke.as_ref().map_or(0.0, |s| s.width); + let area = Rect::new( + (b.left() - reach).floor() as i32, + (b.top() - reach).floor() as i32, + (b.right() + reach).ceil() as i32, + (b.bottom() + reach).ceil() as i32, + ) + .intersect(&self.canvas); + let pixels = u64::from(area.width()) * u64::from(area.height()); + if self.can_retain_pixels(pixels) { + sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas)); + let bytes = pixels * self.fmt.bytes_per_pixel() as u64; + self.pixel_budget -= bytes; + self.retained_pixel_bytes += bytes; + } Some(Layer::new(self.name(p.id(), 0), LayerContent::Shape(sh))) } @@ -1111,6 +1471,87 @@ mod tests { 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, 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, 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, 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(" Arc { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(photocraft_text::fonts::INTER_REGULAR.to_vec()); + Arc::new(db) + } + assert!(inter().faces().next().is_some(), "the bundled Inter loads"); + let font = r#"font-size="24" font-family="Inter" fill="black""#; + let cases = [ + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + ]; + for c in cases { + let svg = format!(r#"{c}"#); + let parsed = parse_with(svg.as_bytes(), inter).unwrap(); + assert!(!parsed.fontless_text, "{c}"); + let mut reference = tiny_skia::Pixmap::new(120, 60).unwrap(); + resvg::render(&parsed.tree, tiny_skia::Transform::default(), &mut reference.as_mut()); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, 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); + let (mut both, mut either) = (0usize, 0usize); + let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]); + let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)]; + for (i, px) in reference.pixels().iter().enumerate() { + let (x, y) = ((i % 120) as i32, (i / 120) as i32); + let (a, b) = (imported.get(x, y)[3] > 0.5, px.alpha() > 127); + if a { + grow(&mut a_box, x, y); + } + if b { + grow(&mut b_box, x, y); + } + both += usize::from(a && b); + either += usize::from(a || b); + } + assert!(either > 50, "resvg draws the text on the canvas: {c}"); + // Same place and size to the pixel; the outlines are filled by a different rasteriser, + // so the edges of thin stems may differ by half a pixel. + assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}"); + assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}"); + } + } + /// #4266: a group clipped by one path (the PhotoCraft icon's rounded square) stays editable /// shapes with the clip as its vector mask, and looks as resvg draws the drawing; a clip of /// two shapes is still rasterised, with the warning. @@ -1275,6 +1774,201 @@ mod tests { assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); } + /// A database with the bundled Inter alone, generics resolved without Fontconfig: none of + /// fontdb's Windows defaults (Arial, Times New Roman, Courier New) is in it. + fn inter_only() -> Arc { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + resolve_generic_families(&mut db, |_| None); + Arc::new(db) + } + + /// Text asking for fonts that aren't installed (`Arial, Helvetica, sans-serif` on a Linux + /// without them) is set in an installed font, as a browser does, instead of being dropped. + #[test] + fn text_in_missing_fonts_falls_back_to_an_installed_one() { + for family in ["Arial, Helvetica, sans-serif", "Times New Roman", "Courier New, monospace", "cursive", "fantasy"] { + let svg = format!( + "II" + ); + let parsed = parse_with(svg.as_bytes(), inter_only).unwrap(); + assert!(!parsed.fontless_text); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap(); + assert_eq!(r.document.layers.first().map(|l| l.name.as_str()), Some("II"), "{family}: {:?}", r.warnings); + } + } + + /// #9406: a font installed on disk (how the system fonts load) is read once, not once per + /// glyph: fontdb re-reads a file source's whole font on every glyph it outlines, about a + /// millisecond a character. The face text is set in comes from memory, and the outlines + /// match the same font loaded from memory in the first place. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn text_in_a_font_on_disk_is_set_from_memory() { + fn inter_on_disk() -> Arc { + let mut db = usvg::fontdb::Database::new(); + let _ = db.load_font_file(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf")); + Arc::new(db) + } + assert!(inter_on_disk().faces().all(|f| matches!(f.source, usvg::fontdb::Source::File(_)))); + let svg = b"IHI"; + 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, 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 }); + groups + .flat_map(|g| &g.children) + .filter_map(|l| if let LayerContent::Shape(s) = &l.content { Some(s.path.clone()) } else { None }) + .collect::>() + }; + assert!(outlines(&on_disk).iter().any(|p| !p.subpaths.is_empty()), "the text has outlines"); + assert_eq!(outlines(&on_disk), outlines(&in_memory)); + } + + /// A font file that no longer loads (replaced or cut short since fontdb indexed it) keeps its + /// faces in the database and isn't remembered: once the file is whole again, text is set + /// from it. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_font_file_gone_bad_since_indexing_is_not_cached() { + use std::sync::atomic::{AtomicBool, Ordering}; + static BAD: AtomicBool = AtomicBool::new(true); + fn indexed_then_overwritten() -> Arc { + let path = std::env::temp_dir().join(format!("photocraft-svg-test-bad-{}.ttf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let mut db = usvg::fontdb::Database::new(); + db.load_font_file(&path).unwrap(); + if BAD.load(Ordering::SeqCst) { + std::fs::write(&path, b"not a font any more").unwrap(); + } + Arc::new(db) + } + let svg = b"IHI"; + let parsed = parse_with(svg, indexed_then_overwritten).unwrap(); + let sources: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::File(_))).collect(); + assert_eq!(sources, [true], "the face stays in the database, on disk"); + BAD.store(false, Ordering::SeqCst); + 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, 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())))), + "the text has outlines" + ); + } + + /// Imports that miss the cache for the same file at once read it once: every one sets its + /// text from the same bytes, not a copy of its own next to the kept one. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn imports_missing_the_same_font_at_once_share_one_read() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static ROUND: AtomicUsize = AtomicUsize::new(0); + fn fresh_file() -> Arc { + let round = ROUND.load(Ordering::SeqCst); + let path = std::env::temp_dir().join(format!("photocraft-svg-test-shared-{}-{round}.ttf", std::process::id())); + let mut db = usvg::fontdb::Database::new(); + db.load_font_file(&path).unwrap(); + Arc::new(db) + } + let svg = b"IHI"; + for round in 0..5 { + ROUND.store(round, Ordering::SeqCst); + let path = std::env::temp_dir().join(format!("photocraft-svg-test-shared-{}-{round}.ttf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let start = Arc::new(std::sync::Barrier::new(8)); + let threads: Vec<_> = (0..8) + .map(|_| { + let start = start.clone(); + std::thread::spawn(move || { + start.wait(); + let parsed = parse_with(svg, fresh_file).unwrap(); + let bytes: Vec<_> = parsed + .tree + .fontdb() + .faces() + .filter_map(|f| if let usvg::fontdb::Source::Binary(b) = &f.source { Some(Arc::as_ptr(b).cast::() as usize) } else { None }) + .collect(); + bytes + }) + }) + .collect(); + let bytes: Vec<_> = threads.into_iter().flat_map(|t| t.join().unwrap()).collect(); + assert_eq!(bytes.len(), 8, "every import set its text from memory"); + assert!(bytes.iter().all(|&b| b == bytes[0]), "round {round}: {bytes:x?}"); + let _ = std::fs::remove_file(&path); + } + } + + /// #9406: moving a font collection into memory keeps its faces in their order, so the + /// fallback search for a later text still finds the face it found on disk first. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_collection_in_memory_keeps_its_face_order() { + fn collection_on_disk() -> Arc { + // A two-face .ttc of the bundled Inter Regular and SemiBold: the `ttcf` header, then + // each font with its table offsets moved by where it starts. + let fonts: [&[u8]; 2] = [include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")]; + let mut ttc = b"ttcf".to_vec(); + let mut start = 20; + let mut body = Vec::new(); + for f in fonts { + ttc.extend_from_slice(&u32::try_from(start).unwrap().to_be_bytes()); + let mut font = f.to_vec(); + for t in 0..usize::from(u16::from_be_bytes([font[4], font[5]])) { + let at = 12 + t * 16 + 8; + let off = u32::from_be_bytes(font[at..at + 4].try_into().unwrap()) + u32::try_from(start).unwrap(); + font[at..at + 4].copy_from_slice(&off.to_be_bytes()); + } + font.resize(font.len().next_multiple_of(4), 0); + start += font.len(); + body.extend(font); + } + ttc.extend(body); + let path = std::env::temp_dir().join(format!("photocraft-svg-test-{}.ttc", std::process::id())); + std::fs::write(&path, ttc).unwrap(); + let mut db = usvg::fontdb::Database::new(); + db.load_font_file(&path).unwrap(); + Arc::new(db) + } + let order = |db: &usvg::fontdb::Database| db.faces().map(|f| (f.index, f.weight.0)).collect::>(); + assert_eq!(order(&collection_on_disk()), [(0, 400), (1, 600)]); + let svg = b"IHI"; + let parsed = parse_with(svg, collection_on_disk).unwrap(); + let db = parsed.tree.fontdb(); + assert!(db.faces().all(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))), "both faces of the file are in memory"); + assert_eq!(order(db), [(0, 400), (1, 600)]); + } + + #[test] + fn generic_families_point_at_installed_fonts() { + use usvg::fontdb::Family; + let db = inter_only(); + for g in [Family::SansSerif, Family::Serif, Family::Monospace, Family::Cursive, Family::Fantasy] { + assert_eq!(db.family_name(&g), "Inter", "{g:?}"); + } + // The system's choice wins when installed; a generic whose font is installed stays. + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_data(include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec()); + db.set_cursive_family("Inter"); + resolve_generic_families(&mut db, |g| Some(if g == "serif" { "JetBrains Mono" } else { "Not Installed" }.to_string())); + assert_eq!(db.family_name(&Family::Serif), "JetBrains Mono"); + assert_eq!(db.family_name(&Family::Monospace), "JetBrains Mono", "the bundled mono stand-in"); + assert_eq!(db.family_name(&Family::SansSerif), "Inter"); + assert_eq!(db.family_name(&Family::Cursive), "Inter"); + // An empty database is left alone. + let mut empty = usvg::fontdb::Database::new(); + resolve_generic_families(&mut empty, |_| Some("Inter".into())); + assert_eq!(empty.family_name(&Family::SansSerif), "Arial"); + } + #[test] fn rasterized_groups_stop_at_the_aggregate_pixel_budget() { let mut svg = String::from(""); @@ -1371,7 +2065,7 @@ mod tests { fn inter() -> Arc { let mut db = usvg::fontdb::Database::new(); db.load_font_data(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); - configure_generic_fonts(&mut db); + resolve_generic_families(&mut db, |_| None); Arc::new(db) } let svg=br#"LOGO"#; @@ -1380,7 +2074,69 @@ mod tests { 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() }); - configure_generic_fonts(&mut db); + 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())); + let resolve = &opt.image_href_resolver.resolve_data; + let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255; 12]).unwrap(); + let valid_png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + assert!(resolve("image/png", Arc::new(valid_png.clone()), &opt).is_some()); + assert!(resolve("text/plain", Arc::new(valid_png.clone()), &opt).is_some()); + + let mut huge_png = valid_png; + huge_png[16..20].copy_from_slice(&40_000u32.to_be_bytes()); + huge_png[20..24].copy_from_slice(&40_000u32.to_be_bytes()); + assert!(resolve("image/png", Arc::new(huge_png.clone()), &opt).is_none(), "oversized PNG rejected before decode"); + assert!(resolve("text/plain", Arc::new(huge_png), &opt).is_none(), "oversized mime-less PNG rejected before decode"); + + // Malformed WebP header under image/webp or text/plain must be rejected at the probe step. + let bad_webp = b"RIFF\x10\x00\x00\x00WEBPVP8 ".to_vec(); + assert!(resolve("image/webp", Arc::new(bad_webp.clone()), &opt).is_none()); + assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none()); + } + + #[test] + fn jpeg_dimensions_come_from_the_frame_header_and_oversized_jpegs_are_refused() { + // SOI, an APP0 segment of length 4, then SOF0 (length 11): 8 bits, height 40000, width 30000. + let jpeg = |h: u16, w: u16| { + let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8]; + v.extend(h.to_be_bytes()); + v.extend(w.to_be_bytes()); + v.extend([1, 1, 0x11, 0]); + v + }; + assert_eq!(jpeg_dimensions(&jpeg(40_000, 30_000)), Some((30_000, 40_000))); + assert_eq!(jpeg_dimensions(&[0xFF, 0xD8, 0xFF, 0xDA, 0, 2]), None); + assert_eq!(jpeg_dimensions(&[0xFF, 0xD8]), None); + let opt = options(Arc::new(usvg::fontdb::Database::new())); + let resolve = &opt.image_href_resolver.resolve_data; + // 1.2 billion pixels would be 4.8 GB of RGBA before resvg scaled anything down. + assert!(resolve("image/jpeg", Arc::new(jpeg(40_000, 30_000)), &opt).is_none()); + assert!(resolve("text/plain", Arc::new(jpeg(40_000, 30_000)), &opt).is_none()); + } + + #[test] + fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() { + use std::io::Write; + fn gz(bytes: &[u8]) -> Vec { + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + e.write_all(bytes).unwrap(); + e.finish().unwrap() + } + let opt = options(Arc::new(usvg::fontdb::Database::new())); + let resolve = &opt.image_href_resolver.resolve_data; + let svg = b""; + let once = gz(svg); + assert!(resolve("image/svg+xml", Arc::new(once.clone()), &opt).is_some()); + assert!(resolve("text/plain", Arc::new(once.clone()), &opt).is_some()); + + // Doubly gzip-compressed payload must be rejected rather than decompressed a second time by usvg. + let twice = gz(&once); + assert!(resolve("image/svg+xml", Arc::new(twice.clone()), &opt).is_none()); + assert!(resolve("text/plain", Arc::new(twice), &opt).is_none()); + } } diff --git a/crates/io/src/text_import.rs b/crates/io/src/text_import.rs index 9272f0b5b7..0522f39e90 100644 --- a/crates/io/src/text_import.rs +++ b/crates/io/src/text_import.rs @@ -28,8 +28,14 @@ fn render_bounded(eng: &mut photocraft_text::TextEngine, t: &mut TextLayer, dpi: /// - a type layer without pixels (ag-psd, GIMP and other writers leave the image data out and /// let Photoshop re-render on open) is rendered from its model, instead of staying empty /// until it is edited. -pub(crate) fn prepare(doc: &mut Document) { - fn visit(layers: &mut [Layer], eng: &mut photocraft_text::TextEngine, dpi: f32, format: photocraft_color::PixelFormat) { +pub(crate) fn prepare(doc: &mut Document, txt2: Option<&photocraft_text::engine_data::Value>) { + fn visit( + layers: &mut [Layer], + eng: &mut photocraft_text::TextEngine, + dpi: f32, + format: photocraft_color::PixelFormat, + txt2: Option<&photocraft_text::engine_data::Value>, + ) { for l in layers { match &mut l.content { LayerContent::Text(t) => { @@ -56,11 +62,12 @@ pub(crate) fn prepare(doc: &mut Document) { if changed { t.sync_summary(); } + photocraft_text::psd::refine_legacy_point_origin(t, dpi, eng, txt2); if t.cache.is_none() { render_bounded(eng, t, dpi, format); } } - LayerContent::Group(g) => visit(&mut g.children, eng, dpi, format), + LayerContent::Group(g) => visit(&mut g.children, eng, dpi, format, txt2), _ => {} } } @@ -68,5 +75,5 @@ pub(crate) fn prepare(doc: &mut Document) { let dpi = doc.resolution_dpi; let format = doc.pixel_format(); let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); - visit(&mut doc.layers, &mut eng, dpi, format); + visit(&mut doc.layers, &mut eng, dpi, format, txt2); } diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 6caf335768..2949a551cc 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -11,11 +11,13 @@ use std::sync::Arc; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample}; -use photocraft_color::ColorMode; -use photocraft_doc::Document; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document}; +use photocraft_geom::Rect; use photocraft_psd::resources::ids; use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes, resources_to_bytes}; use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, ImageResource, ResolutionInfo}; +use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; use crate::psd_export::PsdExportOptions; @@ -101,13 +103,36 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result 32, }; let (w, h) = img.dimensions(); - let channels = u16::try_from(img.layout().channels()).map_err(|_| IoError::Unsupported("too many channels".into()))?; + 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()))?; let header = Header::new(isd.version, w, h, channels, depth, mode); - let data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?; let image_data = ImageData { compression: Compression::Raw, data }; - let file = isd.into_psd(header, resources, image_data); + let mut file = isd.into_psd(header, resources, image_data); + // 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. + file.global_blocks.retain(|b| { + let empty_pattern = matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3") && b.data.is_empty(); + let default_filter_mask = &b.key == b"FMsk" && b.data.as_slice() == FILTER_MASK_DEFAULT; + !empty_pattern && !default_filter_mask + }); // The same nesting limit as opening a PSD (#2940). crate::psd_import::check_group_depth(&file)?; + // 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); doc.name = name.to_string(); @@ -200,22 +225,28 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr } let mut warnings = Vec::new(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; - // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF - // keeps straight alpha) and uncompressed (its planes are interleaved below). + // The PSD exporter renders the composite we need for the TIFF itself, uncompressed (its + // planes are interleaved below). A spot (or any user channel) is saved the way Photoshop's + // TIFF dialog does with "Save Transparency" off: the composite is colour plus those + // channels, matted on white, and each layer keeps its own transparency. With no user + // channel the composite stays unmatted, straight RGBA, which is what a plain transparent + // TIFF already round-trips as. // Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as // Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a // TIFF and opens only the composite, as one locked Background. The exporter's size estimate // counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB // while its real layer data is a small fraction of that; it is not used here. PSB-style data // is written only past 30000 px, when asked for, or when a length really overflows 32 bits. + let user_channels = !doc.channels.is_empty() || doc.quick_mask.is_some(); let force_psb = opts.force_psb || big; let psd_opts = PsdExportOptions { force_psb, size_limit_psb: false, - merged_matte: false, + merged_matte: user_channels, merged_compression: Compression::Raw, maximize_compatibility: true, document_path: opts.document_path.clone(), + read_linked_files: opts.read_linked_files, }; // 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 @@ -233,7 +264,7 @@ 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 ImageSourceData::from_psd(&file).to_bytes(order) { + let (layers, w) = match tiff_image_source(doc, &file).to_bytes(order) { Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); @@ -241,7 +272,7 @@ 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; - ImageSourceData::from_psd(&file).to_bytes(order)? + tiff_image_source(doc, &file).to_bytes(order)? } r => r?, }; @@ -251,24 +282,60 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // The profile and XMP are written as TIFF tags, not as resources. EXIF stays a resource // (1058), which the PSD importer reads back unchanged; a block rebuilt from the TIFF's own // Exif directory keeps only the Exif, GPS and descriptive tags. - let resources: Vec = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect(); - let resources = resources_to_bytes(&resources)?; + let mut resources: Vec = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect(); 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 = usize::from(file.header.channels).saturating_sub(channels); - if extra > 0 { - warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)")); + 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; + // 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))); + } + // 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. + if has_alpha + && kept > 0 + && let Some(r) = resources.iter_mut().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO) + && r.data.len() >= 2 + { + let id = u16::from_be_bytes([r.data[0], r.data[1]]).saturating_sub(1); + let be = id.to_be_bytes(); + r.data[0] = be[0]; + r.data[1] = be[1]; + } + // The name and DisplayInfo resources list the merged transparency plane first. The IFD + // leaves that plane out, so the resources must too: Photoshop pairs each name with the + // next sample past the colour channels. + if has_alpha && !write_alpha { + drop_merged_transparency(&mut resources); + } + // 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. + if extra_n > kept { + keep_first_channels(&mut resources, kept, cc); + } + let resources = resources_to_bytes(&resources)?; + let ifd_channels = cc + usize::from(write_alpha); let sample = csample(fmt.sample); let (w, h) = (doc.size.width, doc.size.height); let cmyk = fmt.mode == ColorMode::Cmyk; - let data = interleave(&file.image_data.data, w, h, channels, cc, sample, 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)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); - let layout = layout_for(fmt.mode, has_alpha); + let layout = layout_for(fmt.mode, write_alpha); let mut img = Image::from_raw(w, h, layout, sample, data)?; img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); img.meta = codecs::Metadata { @@ -276,16 +343,185 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // importer reads it back from there unchanged. A second copy in `meta.exif` (the codec's // Exif directory) would be read back in its place, rebuilt without the thumbnail and IFD0 // orientation (#1545). - xmp: doc.metadata.xmp.clone().filter(|_| opts.xmp == crate::XmpEmbed::All), + xmp: doc.metadata.xmp.as_deref().map(str::to_owned).filter(|_| opts.xmp == crate::XmpEmbed::All), text: if opts.xmp == crate::XmpEmbed::All { doc.metadata.text.clone() } else { Vec::new() }, dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), photoshop_resources: Some(resources), photoshop_layers: Some(layers), + extra_samples: extras, ..Default::default() }; encode_image(&img, Format::Tiff, opts, warnings) } +/// 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 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); + 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 { + isd.global_blocks.retain(|b| !b.is_merged_transparency_marker()); + } + if let Some(info) = &mut isd.layer_info { + for layer in &mut info.layers { + // Photoshop sets flag bit 3 on every record it writes, group dividers included (299 of + // the 305 dividers in the corpus's Photoshop files). PhotoCraft's PSDs set it on + // layers only, unchanged here. + layer.flags.0 |= photocraft_psd::layer::LayerFlags::BIT4_USEFUL; + photoshop_layer_blocks(&mut layer.blocks); + } + } + // Photoshop reads a fixed 14-byte user-mask record from `LMsk` and an empty `Patt` + // before the filter mask. A zero-length `LMsk` is what makes it report that the + // layer data could not be read. + if let Some(mask) = &mut isd.global_layer_mask + && mask.data.len() < 14 + { + *mask = photocraft_psd::GlobalLayerMask::photoshop_default(); + } + if !isd.global_blocks.iter().any(|b| matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3")) { + isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"Patt", Vec::new())); + } + 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())); + } + isd +} + +/// Removes the leading transparency entry from the channel-name resources (1006, 1045) and +/// from DisplayInfo (1077). Each is left unchanged when it is too short to hold that entry. +fn drop_merged_transparency(resources: &mut [ImageResource]) { + for r in resources { + r.data = match r.id { + 1006 => drop_first_pascal(&r.data), + 1045 => drop_first_unicode(&r.data), + id if id == crate::channel_map::DISPLAY_INFO => drop_first_display_record(&r.data), + _ => continue, + }; + } +} + +fn drop_first_pascal(data: &[u8]) -> Vec { + let Some(&n) = data.first() else { return data.to_vec() }; + let end = 1usize.saturating_add(usize::from(n)); + data.get(end..).unwrap_or(data).to_vec() +} + +fn drop_first_unicode(data: &[u8]) -> Vec { + let Some(nbe) = data.get(..4) else { return data.to_vec() }; + let n = u32::from_be_bytes([nbe[0], nbe[1], nbe[2], nbe[3]]) as usize; + let end = 4usize.saturating_add(n.saturating_mul(2)); + data.get(end..).unwrap_or(data).to_vec() +} + +/// Trims the channel-name resources (1006, 1045) and DisplayInfo (1077) to their first `kept` +/// entries, and drops Quick Mask info (1022) when its channel is not one of the `kept` extra +/// samples, which start at sample `first_extra`. A resource too short for `kept` entries is +/// left as it is. +fn keep_first_channels(resources: &mut Vec, kept: usize, first_extra: usize) { + for r in resources.iter_mut() { + r.data = match r.id { + 1006 => first_pascal_names(&r.data, kept), + 1045 => first_unicode_names(&r.data, kept), + id if id == crate::channel_map::DISPLAY_INFO => first_display_records(&r.data, kept), + _ => continue, + }; + } + let end = first_extra.saturating_add(kept); + resources.retain(|r| r.id != crate::channel_map::QUICK_MASK_INFO || r.data.get(..2).is_none_or(|b| usize::from(u16::from_be_bytes([b[0], b[1]])) < end)); +} + +fn first_pascal_names(data: &[u8], n: usize) -> Vec { + let mut at = 0usize; + for _ in 0..n { + let Some(&len) = data.get(at) else { return data.to_vec() }; + at = at.saturating_add(1).saturating_add(usize::from(len)); + } + data.get(..at).unwrap_or(data).to_vec() +} + +fn first_unicode_names(data: &[u8], n: usize) -> Vec { + let mut at = 0usize; + for _ in 0..n { + let Some(b) = data.get(at..at.saturating_add(4)) else { return data.to_vec() }; + let len = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize; + at = at.saturating_add(4).saturating_add(len.saturating_mul(2)); + } + data.get(..at).unwrap_or(data).to_vec() +} + +/// Resource 1077: a 4-byte version, then 13-byte records. Keeps the first `n` records. +fn first_display_records(data: &[u8], n: usize) -> Vec { + let end = 4usize.saturating_add(n.saturating_mul(13)); + data.get(..end).unwrap_or(data).to_vec() +} + +/// Resource 1077: a 4-byte version, then 13-byte records. Drops the first record. +fn drop_first_display_record(data: &[u8]) -> Vec { + const RECORD: usize = 13; + if data.len() < 4 + RECORD { + return data.to_vec(); + } + let mut out = Vec::with_capacity(data.len() - RECORD); + out.extend_from_slice(&data[..4]); + out.extend_from_slice(&data[4 + RECORD..]); + out +} + +/// Filter-mask block (`FMsk`) Photoshop writes next to a layered TIFF: the same red 50% +/// overlay as [`photocraft_psd::GlobalLayerMask::photoshop_default`], without the kind byte. +const FILTER_MASK_DEFAULT: &[u8] = &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50]; + +/// Blocks Photoshop writes on every layer of a layered TIFF, when the layer does not +/// already carry them. Defaults match a plain pixel layer (clipped-as-group on, nothing +/// locked, no label). +fn photoshop_layer_blocks(blocks: &mut Vec) { + let has = |k: &[u8; 4]| blocks.iter().any(|b| &b.key == k); + let mut add = Vec::new(); + if !has(b"lnsr") { + add.push(photocraft_psd::TaggedBlock::name_source(*b"layr")); + } + if !has(b"clbl") { + add.push(photocraft_psd::TaggedBlock::blend_clipped_as_group(true)); + } + if !has(b"infx") { + add.push(photocraft_psd::TaggedBlock::blend_interior_elements(false)); + } + if !has(b"knko") { + add.push(photocraft_psd::TaggedBlock::knockout(0)); + } + if !has(b"lspf") { + add.push(photocraft_psd::TaggedBlock::protection(0)); + } + if !has(b"lclr") { + add.push(photocraft_psd::TaggedBlock::sheet_color(0)); + } + let at = blocks + .iter() + .position(|b| &b.key == b"lyid") + .map(|i| i + 1) + .or_else(|| blocks.iter().position(|b| &b.key == b"luni").map(|i| i + 1)) + .unwrap_or(blocks.len()); + for (i, block) in add.into_iter().enumerate() { + blocks.insert(at.saturating_add(i), block); + } +} + /// Removes the blocks a document kept verbatim from a little-endian TIFF because their layout /// is unknown ([`photocraft_psd::TaggedBlock::is_foreign_order`]) before `file` is written as a /// PSD: their bytes are in the wrong order for a PSD and cannot be converted. Returns a warning @@ -315,6 +551,418 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec Result, IoError> { + if bps == 1 { + return Ok(src.to_vec()); + } + 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()); + for c in src.chunks_exact(bps) { + match (bps, to_be, c) { + (2, true, [a, b]) => { + let v = u16::from_ne_bytes([*a, *b]); + out.extend_from_slice(&v.to_be_bytes()); + } + (2, false, [a, b]) => { + let v = u16::from_be_bytes([*a, *b]); + out.extend_from_slice(&v.to_ne_bytes()); + } + (4, true, [a, b, c, d]) => { + let v = f32::from_ne_bytes([*a, *b, *c, *d]); + out.extend_from_slice(&v.to_be_bytes()); + } + (4, false, [a, b, c, d]) => { + let v = f32::from_be_bytes([*a, *b, *c, *d]); + out.extend_from_slice(&v.to_ne_bytes()); + } + _ => return Err(IoError::Unsupported("an extra channel plane is incomplete".into())), + } + } + Ok(out) +} + +/// Native-endian extra-sample plane → big-endian, for the PSD merged image a layered TIFF rebuilds. +fn native_plane_to_be(src: &[u8], bps: usize) -> Result, IoError> { + swap_plane(src, bps, true) +} + +/// Extra planes of a PSD merged image (`skip` colour-and-alpha planes already consumed), as +/// native-endian TIFF extra samples. A spot plane is already Photoshop ink (0 = solid, max = +/// paper); an alpha plane is already straight. Neither is inverted again here. +fn native_extra_planes(planes: &[u8], w: u32, h: u32, skip: usize, count: usize, sample: CSample) -> Result, IoError> { + let bps = sample.bytes(); + let n = (w as usize).checked_mul(h as usize).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let plane = n.checked_mul(bps).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let at = skip.checked_add(i).and_then(|k| k.checked_mul(plane)).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let Some(src) = planes.get(at..at.saturating_add(plane)) else { + return Err(IoError::Unsupported("the merged image is missing an extra channel".into())); + }; + if src.len() != plane { + return Err(IoError::Unsupported("the merged image is missing an extra channel".into())); + } + out.push(codecs::ExtraSample { kind: 0, data: swap_plane(src, bps, false)? }); + } + Ok(out) +} + +/// One grayscale channel as native-endian samples in `img`'s sample type. +fn channel_plane(channel: &AlphaChannel, canvas: Rect, sample: CSample) -> Result, String> { + let depth = match sample { + CSample::U8 => SampleType::U8, + CSample::U16 => SampleType::U16, + CSample::F16 | CSample::F32 => SampleType::F32, + }; + let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); + let owned; + let surface = if channel.surface.format() == fmt { + &channel.surface + } else { + owned = channel.surface.convert(fmt); + &owned + }; + let mut bytes = surface.to_interleaved(canvas); + let n = (canvas.width() as usize).saturating_mul(canvas.height() as usize); + let expect = n.saturating_mul(depth.bytes()); + if bytes.len() != expect { + return Err(format!("channel \"{}\" does not cover the canvas", channel.name)); + } + if channel.spot.is_some() { + crate::channel_map::photoshop_spot_plane(&mut bytes, depth); + } + if sample == CSample::F16 { + let mut out = Vec::with_capacity(n.saturating_mul(2)); + for arr in bytes.as_chunks::<4>().0 { + let v = f32::from_ne_bytes(*arr); + out.extend_from_slice(&codecs::f16::from_f32(v).to_ne_bytes()); + } + return Ok(out); + } + Ok(bytes) +} + +/// Composites straight alpha over white and drops it. Photoshop's TIFF save with +/// "Save Transparency" off stores that matte in the colour samples and keeps transparency +/// only inside each layer. Fully opaque pixels are copied unchanged, so they stay identical +/// to a composite that was quantized before the matte. `false` when there is no alpha or +/// the buffer is not one sample per channel. +fn matte_and_drop_alpha(img: &mut Image) -> bool { + if !img.layout().has_alpha() { + return false; + } + let (w, h) = img.dimensions(); + let sample = img.sample_type(); + let dst = img.layout().without_alpha(); + let bps = sample.bytes(); + let keep = dst.channels().saturating_mul(bps); + let stride = img.layout().channels().saturating_mul(bps); + if stride == 0 || keep == 0 || bps == 0 || stride <= keep || !img.data().len().is_multiple_of(stride) { + return false; + } + let cmyk = img.layout().is_cmyk(); + let white_byte: u8 = if cmyk { 0 } else { 255 }; + let white_u16: u16 = if cmyk { 0 } else { u16::MAX }; + 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); + 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 }; + let ok = if lab { + let (Some(l), Some(ab)) = (color.get(..bps), color.get(bps..)) else { return false }; + matte_pixel(sample, l, alpha, white_byte, white_u16, white_f, &mut out) && matte_pixel(sample, ab, alpha, 128, 128 * 257, 128.0 / 255.0, &mut out) + } else { + matte_pixel(sample, color, alpha, white_byte, white_u16, white_f, &mut out) + }; + if !ok { + return false; + } + } + let icc = img.icc.clone(); + let meta = img.meta.clone(); + let warnings = img.warnings.clone(); + let Ok(mut next) = Image::from_raw(w, h, dst, sample, out) else { return false }; + next.icc = icc; + next.meta = meta; + next.warnings = warnings; + *img = next; + true +} + +/// One pixel of [`matte_and_drop_alpha`]. Opaque samples are copied; a clear pixel becomes +/// `white`; anything in between is `c·a + white·(1−a)` in normalized space. +fn matte_pixel(sample: CSample, color: &[u8], alpha: &[u8], white_byte: u8, white_u16: u16, white_f: f32, out: &mut Vec) -> bool { + match sample { + CSample::U8 => { + let Some(&a) = alpha.first() else { return false }; + if a == 255 { + out.extend_from_slice(color); + } else if a == 0 { + out.resize(out.len().saturating_add(color.len()), white_byte); + } else { + let af = f32::from(a) / 255.0; + for &c in color { + let m = crate::pixels::matte(f32::from(c) / 255.0, af, white_f); + out.push((m.clamp(0.0, 1.0) * 255.0).round() as u8); + } + } + } + CSample::U16 => { + let Some(a) = ne_u16(alpha) else { return false }; + if a == u16::MAX { + out.extend_from_slice(color); + } else if a == 0 { + for _ in 0..color.len() / 2 { + out.extend_from_slice(&white_u16.to_ne_bytes()); + } + } else { + let af = f32::from(a) / 65535.0; + for pair in color.as_chunks::<2>().0 { + let c = u16::from_ne_bytes(*pair); + let m = crate::pixels::matte(f32::from(c) / 65535.0, af, white_f); + out.extend_from_slice(&((m.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()); + } + } + } + CSample::F32 => { + let Some(a) = ne_f32(alpha) else { return false }; + if a == 1.0 { + out.extend_from_slice(color); + } else if a == 0.0 || !a.is_finite() { + for _ in 0..color.len() / 4 { + out.extend_from_slice(&white_f.to_ne_bytes()); + } + } else { + let af = a.clamp(0.0, 1.0); + for quad in color.as_chunks::<4>().0 { + let m = crate::pixels::matte(f32::from_ne_bytes(*quad), af, white_f); + out.extend_from_slice(&m.to_ne_bytes()); + } + } + } + CSample::F16 => { + let Some(bits) = ne_u16(alpha) else { return false }; + let a = codecs::f16::from_bits(bits).to_f32(); + if a == 1.0 { + out.extend_from_slice(color); + } else if a == 0.0 || !a.is_finite() { + let w = codecs::f16::from_f32(white_f).to_bits().to_ne_bytes(); + for _ in 0..color.len() / 2 { + out.extend_from_slice(&w); + } + } else { + let af = a.clamp(0.0, 1.0); + for pair in color.as_chunks::<2>().0 { + let c = codecs::f16::from_bits(u16::from_ne_bytes(*pair)).to_f32(); + let m = codecs::f16::from_f32(crate::pixels::matte(c, af, white_f)).to_bits(); + out.extend_from_slice(&m.to_ne_bytes()); + } + } + } + } + true +} + +fn ne_u16(b: &[u8]) -> Option { + let arr: [u8; 2] = b.get(..2)?.try_into().ok()?; + Some(u16::from_ne_bytes(arr)) +} + +fn ne_f32(b: &[u8]) -> Option { + let arr: [u8; 4] = b.get(..4)?.try_into().ok()?; + Some(f32::from_ne_bytes(arr)) +} + +/// 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 { + let total = doc.channels.len().saturating_add(usize::from(doc.quick_mask.is_some())); + if total == 0 { + return Vec::new(); + } + let mut warnings = Vec::new(); + let (w, h) = img.dimensions(); + if w != doc.size.width || h != doc.size.height { + warnings.push("extra channels were not written: the image size differs from the document".into()); + return warnings; + } + let max = codecs::MAX_EXTRA_SAMPLES; + let mut picked: Vec<&AlphaChannel> = doc.channels.iter().take(max).collect(); + if let Some(q) = &doc.quick_mask + && picked.len() < max + { + picked.push(q); + } + if total > picked.len() { + warnings.push(format!("{total} extra channels; TIFF keeps the first {}", picked.len())); + } + let canvas = Rect::new(0, 0, w as i32, h as i32); + let mut planes = Vec::with_capacity(picked.len()); + for ch in &picked { + match channel_plane(ch, canvas, img.sample_type()) { + Ok(data) => planes.push(data), + Err(msg) => { + warnings.push(msg); + break; + } + } + } + picked.truncate(planes.len()); + if picked.is_empty() { + return warnings; + } + let names: Vec<&str> = picked.iter().map(|c| c.name.as_str()).collect(); + let quick_last = matches!((picked.last(), doc.quick_mask.as_ref()), (Some(p), Some(q)) if std::ptr::eq(*p, q)); + let color_channels = img.layout().color_channels(); + // Save Transparency off: the colour samples are the composite over white, and the + // alpha plane is not an extra sample (it would be named as the first spot). The + // composite transparency is gone after this; say so, because the user did not pick + // that option themselves. + if img.layout().has_alpha() { + if !matte_and_drop_alpha(img) { + warnings.push("extra channels were not written: the composite transparency could not be separated from them".into()); + return warnings; + } + warnings.push( + "transparency composited over white; a TIFF with alpha or spot channels stores them as extra samples and drops image transparency (Save Transparency off)" + .into(), + ); + } + let quick_id = if quick_last { u16::try_from(color_channels.saturating_add(picked.len()).saturating_sub(1)).ok() } else { None }; + match channel_resources(img.meta.photoshop_resources.as_deref(), &names, &picked, quick_id) { + Ok(bytes) => img.meta.photoshop_resources = Some(bytes), + Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")), + } + img.meta.extra_samples = planes.into_iter().map(|data| codecs::ExtraSample { kind: 0, data }).collect(); + warnings +} + +/// Existing Photoshop resources with the channel-name, display-info and quick-mask blocks replaced. +fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&AlphaChannel], quick_id: Option) -> Result, IoError> { + let mut resources = match existing.filter(|b| !b.is_empty()) { + Some(b) => resources_from_bytes(b)?.0, + None => Vec::new(), + }; + 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()))); + if let Some(id) = quick_id { + let mut data = id.to_be_bytes().to_vec(); + data.push(0); + resources.push(ImageResource::new(crate::channel_map::QUICK_MASK_INFO, data)); + } + Ok(resources_to_bytes(&resources)?) +} + +/// Extra samples of a flat TIFF become alpha / spot channels. Layered TIFFs rebuild those +/// through the PSD importer instead, so this is only for a file with no layer tag. +pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec { + if img.meta.extra_samples.is_empty() { + return Vec::new(); + } + let mut warnings = Vec::new(); + let (w, h) = img.dimensions(); + if w != doc.size.width || h != doc.size.height { + warnings.push("extra TIFF samples were dropped: their size differs from the image".into()); + return warnings; + } + let parsed = img.meta.photoshop_resources.as_deref().filter(|b| !b.is_empty()).and_then(|b| resources_from_bytes(b).ok()); + let none = Vec::new(); + let resources = parsed.as_ref().map(|(r, _)| r.as_slice()).unwrap_or(&none); + let all_names = resources.iter().find(|r| r.id == 1045).map(|r| crate::psd_import::unicode_names(&r.data)).unwrap_or_default(); + // As in the PSD importer: Photoshop lists the image transparency first ("Transparency", with + // its own display record), ahead of the channels the extra samples hold. + let skip = usize::from(img.layout().has_alpha() && all_names.len() == img.meta.extra_samples.len().saturating_add(1)); + let names = all_names.get(skip..).unwrap_or_default(); + let spots = if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, skip) + } else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, skip) + } else { + Vec::new() + }; + let start = doc.channels.len(); + let n_px = (w as usize).saturating_mul(h as usize); + for (k, e) in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES).enumerate() { + let spot = spots.get(k).copied().unwrap_or(false); + match mask_from_plane(&e.data, w, h, n_px, img.sample_type(), doc.depth, spot) { + Ok(surface) => { + let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Alpha {}", k + 1)); + doc.channels.push(AlphaChannel::new(name, surface)); + } + Err(msg) => warnings.push(msg), + } + } + if doc.channels.len() == start { + return warnings; + } + { + let slice = &mut doc.channels[start..]; + if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) { + crate::channel_map::apply_display_info(&r.data, true, skip, slice); + } else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::apply_display_info(&r.data, false, skip, slice); + } + } + let first = img.layout().color_channels().saturating_add(usize::from(img.layout().has_alpha())); + if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO) + && r.data.len() >= 2 + && let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first) + && start.saturating_add(k) < doc.channels.len() + { + doc.quick_mask = Some(doc.channels.remove(start + k)); + } + warnings +} + +/// Native-endian extra-sample bytes → a grayscale surface at the document depth. +/// `photoshop_spot`: the sample is a spot channel in Photoshop's polarity (0 = solid ink). +fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: SampleType, photoshop_spot: bool) -> Result { + let rect = Rect::new(0, 0, w as i32, h as i32); + let mut bytes = match (from, depth) { + (CSample::U8, SampleType::U8) | (CSample::U16, SampleType::U16) | (CSample::F32, SampleType::F32) => { + if data.len() != n.saturating_mul(from.bytes()) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + data.to_vec() + } + (CSample::F16, SampleType::F32) => { + if data.len() != n.saturating_mul(2) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + let mut out = Vec::with_capacity(n.saturating_mul(4)); + for arr in data.as_chunks::<2>().0 { + let v = codecs::f16::from_bits(u16::from_ne_bytes(*arr)).to_f32(); + out.extend_from_slice(&v.to_ne_bytes()); + } + out + } + _ => return Err(format!("an extra TIFF sample ({from:?}) does not match the document depth and was dropped")), + }; + if photoshop_spot { + crate::channel_map::photoshop_spot_plane(&mut bytes, depth); + } + if bytes.len() != n.saturating_mul(depth.bytes()) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + s.write_interleaved(rect, &bytes); + s.prune(); + Ok(s) +} + #[cfg(test)] mod tests { use photocraft_doc::{Color, Document, Fill, FillCache, Layer, LayerContent}; diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index 3bd127445d..621edf47b8 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -258,10 +258,20 @@ pub fn parse_vstk(data: &[u8], dpi: f32) -> Option { _ => Vec::new(), }; let paint = get_desc(&d, "strokeStyleContent").and_then(content_fill).unwrap_or(Fill::Solid(photocraft_color::Color::BLACK)); + let mut opacity = num(d.get("strokeStyleOpacity")).map_or(1.0, |v| v as f32 / 100.0); + // Write folds colour alpha into strokeStyleOpacity for Photoshop. Restore both Appearance + // fields when pcAl is present so PhotoCraft does not show a maxed colour alpha (#6180). + if let Fill::Solid(c) = &paint + && c.alpha.is_finite() + && c.alpha > 1e-6 + && (c.alpha - 1.0).abs() > 1e-6 + { + opacity = (opacity / c.alpha).clamp(0.0, 1.0); + } let stroke = ShapeStroke { width: unit_px(d.get("strokeStyleLineWidth"), dpi).unwrap_or(1.0) as f32, paint, - opacity: num(d.get("strokeStyleOpacity")).map_or(1.0, |v| v as f32 / 100.0), + opacity, align, cap, join, @@ -276,10 +286,28 @@ fn en(ty: &str, v: &str) -> Value { Value::Enumerated { type_id: Id::new(ty), value: Id::new(v) } } +/// Photoshop's stroke colour has no alpha. Fold the colour's alpha into `strokeStyleOpacity` +/// so Appearance still matches after a PSD round-trip (#6180). +fn fold_stroke_color_alpha(s: &ShapeStroke) -> ShapeStroke { + let mut out = s.clone(); + if let Fill::Solid(c) = &mut out.paint + && c.alpha.is_finite() + && (c.alpha - 1.0).abs() > 1e-6 + { + out.opacity = (out.opacity * c.alpha).clamp(0.0, 1.0); + c.alpha = 1.0; + } + out +} + pub fn vstk_bytes(stroke: Option<&ShapeStroke>, fill_enabled: bool, dpi: f32) -> Vec { let default = ShapeStroke::default(); - let s = stroke.unwrap_or(&default); - let (_, content) = fill_to_desc(&s.paint); + let folded = stroke.map(fold_stroke_color_alpha); + let s = folded.as_ref().unwrap_or(&default); + // Keep pcAl on the original colour so Appearance can restore fill/stroke alphas separately + // from the folded Photoshop opacity (#6180). + let paint = stroke.map(|st| &st.paint).unwrap_or(&s.paint); + let (_, content) = fill_to_desc(paint); let class = match s.paint { Fill::Solid(_) => "solidColorLayer", Fill::Gradient { .. } => "gradientLayer", @@ -660,6 +688,20 @@ mod tests { assert!(off.stroke.is_none() && off.fill_enabled); } + #[test] + fn vstk_folds_color_alpha_into_stroke_opacity() { + let s = ShapeStroke { width: 2.0, paint: Fill::Solid(Color::rgba(0.0, 0.0, 1.0, 0.4)), opacity: 0.5, ..ShapeStroke::default() }; + let bytes = vstk_bytes(Some(&s), true, 72.0); + let raw = crate::blocks::parse_prefix_versioned(&bytes).unwrap(); + let file_opacity = crate::blocks::num(raw.get("strokeStyleOpacity")).unwrap() as f32 / 100.0; + assert!((file_opacity - 0.2).abs() < 1e-4, "Photoshop sees the product in strokeStyleOpacity"); + let got = parse_vstk(&bytes, 72.0).unwrap().stroke.unwrap(); + assert!((got.opacity - 0.5).abs() < 1e-4); + let Fill::Solid(c) = got.paint else { panic!("solid") }; + assert!((c.alpha - 0.4).abs() < 1e-5); + assert!((c.to_rgb()[2] - 1.0).abs() < 1e-3); + } + #[test] fn vogk_roundtrip() { for s in [ @@ -671,7 +713,7 @@ mod tests { let b = vogk_bytes(&s, 72.0).unwrap(); assert_eq!(live_from_vogk(&b), Some(s)); } - assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0 }, 72.0).is_none()); + assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0, corner_radius: 0.0 }, 72.0).is_none()); } #[test] @@ -699,7 +741,7 @@ mod tests { // Edit: path changes → vmsk regenerated, live dropped when polygon. let mut edited = sh.clone(); edited.path.subpaths.pop(); - edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0 }); + edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); shape_blocks(&edited, &mut raw, 64, 64, 72.0); assert!(!raw.iter().any(|(k, _)| k == b"vogk")); let (p, _) = path_from_vmsk(&raw[0].1, 64, 64).unwrap(); diff --git a/crates/io/src/vector_preview.rs b/crates/io/src/vector_preview.rs new file mode 100644 index 0000000000..53bc4c90ba --- /dev/null +++ b/crates/io/src/vector_preview.rs @@ -0,0 +1,562 @@ +//! 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)?) +} + +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_bounded_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 { + 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 { + 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/src/xcf.rs b/crates/io/src/xcf.rs index d5cda4aa4d..5ec8b5edb2 100644 --- a/crates/io/src/xcf.rs +++ b/crates/io/src/xcf.rs @@ -205,15 +205,17 @@ impl<'a> Cursor<'a> { /// A STRING: `n+1` then `n` bytes and a NUL (0 for the empty string). fn string(&mut self) -> Result { + Ok(String::from_utf8_lossy(self.string_bytes()?).into_owned()) + } + + /// The same bounded STRING read without allocating or decoding an unused field. + fn string_bytes(&mut self) -> Result<&'a [u8]> { let n = self.u32()? as usize; - if n == 0 { - return Ok(String::new()); - } if n > MAX_STRING { return Err(invalid(format!("a string of {n} bytes exceeds the limit"))); } let s = self.take(n)?; - Ok(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned()) + Ok(s.strip_suffix(&[0]).unwrap_or(s)) } fn skip(&mut self, n: usize) -> Result<()> { @@ -227,8 +229,8 @@ struct Prop<'a> { data: &'a [u8], } -/// Reads a property list; `fixed` gives the native payload sizes the spec says GIMP trusts over -/// the length word. Ends at PROP_END (type 0) or at the end of the file. +/// Reads a property list, measuring known payloads whose length words GIMP writes incorrectly. +/// Ends at PROP_END (type 0). fn props<'a>(c: &mut Cursor<'a>) -> Result>> { let mut out = Vec::new(); loop { @@ -239,12 +241,14 @@ fn props<'a>(c: &mut Cursor<'a>) -> Result>> { } // PROP_COLORMAP's length word was wrong in some releases (n+4 for 3n+4 bytes); its real // size follows from the count. PROP_USER_UNIT's length is wrong in every release. + // PROP_LINK_LAYER's length can be four bytes short (#8857); read all its fields. let len = match kind { 1 => { let n = c.at(c.pos).u32()? as usize; 4usize.checked_add(n.checked_mul(3).ok_or_else(|| invalid("colormap overflow"))?).ok_or_else(|| invalid("colormap overflow"))? } 24 => user_unit_len(c)?, + 48 => link_layer_len(c)?, _ => len, }; if len > MAX_PAYLOAD { @@ -258,6 +262,16 @@ fn props<'a>(c: &mut Cursor<'a>) -> Result>> { } } +/// A PROP_LINK_LAYER payload: flags, a path STRING, then the source width and height. +/// The dimensions are present for raster links too, where their values are unused. +fn link_layer_len(c: &Cursor<'_>) -> Result { + let mut p = c.at(c.pos); + p.skip(4)?; + p.string_bytes()?; + p.skip(8)?; + Ok(p.pos - c.pos) +} + /// The real size of a PROP_USER_UNIT payload: a float, a count and three (XCF 21+) or five /// strings. Measured without consuming. fn user_unit_len(c: &Cursor<'_>) -> Result { diff --git a/crates/io/src/xcf/tests.rs b/crates/io/src/xcf/tests.rs index 400887f92b..24481f1956 100644 --- a/crates/io/src/xcf/tests.rs +++ b/crates/io/src/xcf/tests.rs @@ -577,6 +577,82 @@ fn clipping_text_vector_effects_and_floating_selections_are_reported() { assert!(w.contains("text layer") && w.contains("vector layer") && w.contains("floating selection"), "{w}"); } +/// #8857: GIMP's link-layer length omits one u32. Trusting it shifts the hierarchy pointer +/// by four bytes, even though the cached pixels and every offset in the file are valid. +#[test] +fn linked_layers_open_cached_pixels_with_short_or_correct_property_lengths() { + for (precision, depth, data) in [ + (150, SampleType::U8, vec![51, 102, 153, 255]), + (250, SampleType::U16, [13107u16, 26214, 39321, 65535].into_iter().flat_map(u16::to_be_bytes).collect()), + (600, SampleType::F32, [0.2f32, 0.4, 0.6, 1.0].into_iter().flat_map(f32::to_be_bytes).collect()), + ] { + for (path, width, height) in [("assets/étiquette.svg", 2000, 1270), ("assets/label.png", 0, 0), ("", 0, 0)] { + let mut payload = W { out: Vec::new(), wide: true }; + payload.u32(1); + payload.string(path); + payload.u32(width); + payload.u32(height); + let payload = payload.out; + let marker = [48u32.to_be_bytes(), (payload.len() as u32).to_be_bytes()].concat(); + let mut img = TestImage::rgb8(1, 1); + img.version = 25; + img.precision = precision; + let mut linked = TestLayer::rgba("Linked", 1, 1, data.clone()); + // Later properties must still be read, including unknown ones skipped by length. + linked.props = vec![(48, payload.clone()), (99, vec![1, 2, 3]), (33, 0.375f32.to_be_bytes().to_vec()), (8, 0u32.to_be_bytes().to_vec())]; + img.layers.push(linked); + img.layers.push(TestLayer::rgba("Bottom", 1, 1, data.clone())); + for short in [false, true] { + let mut bytes = img.write(); + let at = bytes.windows(marker.len()).position(|w| w == marker).unwrap(); + if short { + bytes[at + 4..at + 8].copy_from_slice(&((payload.len() - 4) as u32).to_be_bytes()); + } + let r = open(&bytes).unwrap_or_else(|e| panic!("precision {precision}, path {path:?}, short {short}: {e}")); + assert!(r.source_read_only); + assert_eq!(r.document.depth, depth); + assert_eq!(r.document.layers.len(), 2); + let linked = layer_at(&r.document.layers, "Linked"); + assert_eq!(linked.opacity, 0.375); + assert!(!linked.visible); + for layer in [linked, layer_at(&r.document.layers, "Bottom")] { + for (got, expected) in pixel(&layer, 0, 0).into_iter().zip([0.2, 0.4, 0.6, 1.0]) { + assert!((got - expected).abs() < 1e-5, "precision {precision}: {got} != {expected}"); + } + } + assert!(r.warnings.iter().any(|w| w.contains("is a link layer and opens as pixels"))); + } + } + } +} + +#[test] +fn truncated_and_oversized_link_layer_fields_are_errors() { + let mut payload = W { out: Vec::new(), wide: true }; + payload.u32(1); + payload.string("assets/label.svg"); + payload.u32(2000); + payload.u32(1270); + let payload = payload.out; + let marker = [48u32.to_be_bytes(), (payload.len() as u32).to_be_bytes()].concat(); + let mut img = TestImage::rgb8(1, 1); + img.version = 25; + let mut linked = TestLayer::rgba("Linked", 1, 1, vec![51, 102, 153, 255]); + linked.props.push((48, payload.clone())); + img.layers.push(linked); + let bytes = img.write(); + let start = bytes.windows(marker.len()).position(|w| w == marker).unwrap() + 8; + // Cut within the flags, string length, path and either dimension. + for end in start..start + payload.len() { + assert!(open(&bytes[..end]).is_err(), "truncated at {end}"); + } + for len in [(MAX_STRING + 1) as u32, u32::MAX] { + let mut oversized = bytes.clone(); + oversized[start + 4..start + 8].copy_from_slice(&len.to_be_bytes()); + assert!(open(&oversized).is_err(), "path length {len}"); + } +} + #[test] fn version_3_files_use_32_bit_pointers_and_legacy_modes() { let mut img = TestImage::rgb8(5, 5); diff --git a/crates/io/tests/adjust_blend_roundtrip.rs b/crates/io/tests/it/adjust_blend_roundtrip.rs similarity index 99% rename from crates/io/tests/adjust_blend_roundtrip.rs rename to crates/io/tests/it/adjust_blend_roundtrip.rs index 4e8b727af2..1159836047 100644 --- a/crates/io/tests/adjust_blend_roundtrip.rs +++ b/crates/io/tests/it/adjust_blend_roundtrip.rs @@ -4,7 +4,7 @@ //! document flattens to the same composite (within 1/255) in every case, so nothing a user made //! in PhotoCraft silently disappears or changes when Photoshop opens the file. -mod common; +use crate::common; use std::sync::Arc; @@ -101,7 +101,7 @@ fn adjustments() -> Vec<(Adjustment, bool)> { (Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -30.0, 20.0]) }, true), (Adjustment::SelectiveColor { relative: false, adjustments: std::array::from_fn(|r| [0.0, r as f32 * 5.0, 0.0, -10.0]) }, true), // A block this version cannot read (a truncated Selective Color) is kept verbatim. - (Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] }, true), + (Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0].into() }, true), ]; // Compile-time guard: a new Adjustment kind must be added to the list above. for (a, _) in &all { diff --git a/crates/io/tests/adjust_warnings.rs b/crates/io/tests/it/adjust_warnings.rs similarity index 54% rename from crates/io/tests/adjust_warnings.rs rename to crates/io/tests/it/adjust_warnings.rs index c5bba01827..c9199835d2 100644 --- a/crates/io/tests/adjust_warnings.rs +++ b/crates/io/tests/it/adjust_warnings.rs @@ -6,6 +6,7 @@ use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; use photocraft_geom::Size; use photocraft_io::*; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::{Compression, LayerFlags, LayerRecord, PsdFile, TaggedBlock, Version, testgen}; use photocraft_raster::Surface; fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec) { @@ -19,7 +20,7 @@ fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec Vec { let cube = b"LUT_3D_SIZE 2\n0 0 0\n0 1 1\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + photoshop_clr_l_block_from_cube(cube) +} + +fn photoshop_clr_l_block_from_cube(cube: &[u8]) -> Vec { let en = |t: &str, val: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(val) }; let text = |t: &str| Value::Text(UnicodeString::new_nul(t)); let desc = Descriptor::new("null") @@ -61,13 +66,53 @@ fn photoshop_clr_l_block() -> Vec { raw } +fn identity_cube(domain: &str) -> Vec { + format!("{domain}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").into_bytes() +} + +fn synthetic_lookup_psd(version: Version, depth: u16, cube: &[u8]) -> Vec { + const LAYER_NAME: &str = "Custom-domain LUT"; + let mut file = testgen::layered(version, photocraft_psd::ColorMode::Rgb, depth, Compression::Rle); + let mut flags = LayerFlags::default(); + flags.set_pixel_data_irrelevant(true); + // Photoshop stores layer-block alignment inside the block length. Make the source payload + // canonical so the exporter can preserve it exactly rather than adding alignment on save. + let mut clrl = photoshop_clr_l_block_from_cube(cube); + clrl.resize(clrl.len().next_multiple_of(2), 0); + let record = LayerRecord { + name: LAYER_NAME.as_bytes().to_vec(), + flags, + blocks: vec![TaggedBlock::new(*b"clrL", clrl), TaggedBlock::unicode_name(LAYER_NAME), TaggedBlock::layer_id(9876)], + ..Default::default() + }; + file.layers_mut().insert(0, record); + file.to_bytes().expect("serialize synthetic PSD/PSB") +} + +fn unsupported_lookup_raw(doc: &Document) -> &[u8] { + let layer = doc.layers.iter().find(|layer| layer.name == "Custom-domain LUT").expect("lookup layer"); + let LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) = &layer.content else { + panic!("expected preserved unsupported Color Lookup, got {:?}", layer.content) + }; + assert_eq!(psd_key, "clrL"); + raw +} + +fn parsed_lookup_block(bytes: &[u8]) -> (String, Vec) { + let file = PsdFile::from_bytes(bytes).expect("parse PSD/PSB"); + let layer = file.layers().iter().find(|layer| layer.name() == "Custom-domain LUT").expect("lookup layer record"); + let blocks: Vec<_> = layer.blocks.iter().filter(|block| block.key == *b"clrL").collect(); + assert_eq!(blocks.len(), 1, "lookup blocks on layer: {blocks:?}"); + (layer.name(), blocks[0].data.clone()) +} + #[test] fn photoshop_layout_color_lookup_round_trips_through_psb() { // A kept-raw `clrL` block in Photoshop's layout survives a PSB round trip and reopens as a // working Color Lookup, with no warning naming the layer (#1763). let raw = photoshop_clr_l_block(); let opts = ExportOptions { force_psb: true, ..Default::default() }; - let (doc, warnings) = reopen(Adjustment::Unsupported { psd_key: "clrL".into(), raw: raw.clone() }, "x.psb", &opts); + let (doc, warnings) = reopen(Adjustment::Unsupported { psd_key: "clrL".into(), raw: raw.as_slice().into() }, "x.psb", &opts); assert!(warnings.iter().all(|w| !w.contains("Teal Look")), "{warnings:?}"); let LayerContent::Adjustment(Adjustment::ColorLookup { name, size, lut: Some(table), tetrahedral: false, dither: false }) = &doc.layers[0].content else { panic!("{:?}", doc.layers[0].content) @@ -109,3 +154,59 @@ fn short_color_lookup_table_warns_on_open() { let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default()); assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("no usable table")), "{warnings:?}"); } + +#[test] +fn custom_domain_lookup_warns_and_round_trips_verbatim() { + let domains = ["DOMAIN_MIN 0 0 0\nDOMAIN_MAX 0.5 0.5 0.5\n", "DOMAIN_MIN 0 0.25 0\nDOMAIN_MAX 1 0.75 1\n", "LUT_3D_INPUT_RANGE 0 0.5\n"]; + for domain in domains { + let cube = identity_cube(domain); + for (version, depth, extension) in [ + (Version::Psd, 8, "psd"), + (Version::Psd, 16, "psd"), + (Version::Psd, 32, "psd"), + (Version::Psb, 8, "psb"), + (Version::Psb, 16, "psb"), + (Version::Psb, 32, "psb"), + ] { + let input = synthetic_lookup_psd(version, depth, &cube); + let (_, source_block) = parsed_lookup_block(&input); + let name = format!("input.{extension}"); + let opened = import(&name, &input).expect("open synthetic PSD/PSB"); + assert_eq!(unsupported_lookup_raw(&opened.document), source_block, "{extension}, {depth}-bit, {domain:?}"); + let warnings: Vec<_> = opened.warnings.iter().filter(|warning| warning.contains("Custom-domain LUT")).collect(); + assert_eq!(warnings.len(), 1, "{extension}, {depth}-bit: {:?}", opened.warnings); + assert!( + warnings[0].contains("Color Lookup") + && warnings[0].contains("DOMAIN_MIN") + && warnings[0].contains("DOMAIN_MAX") + && warnings[0].to_ascii_lowercase().contains("not supported"), + "{}", + warnings[0] + ); + + let native = export(&opened.document, "preserved.pcraft", &ExportOptions::default()).expect("save native document"); + let native_open = import("preserved.pcraft", &native.bytes).expect("reopen native document"); + assert_eq!(unsupported_lookup_raw(&native_open.document), source_block, "native preservation, {extension}, {depth}-bit"); + + let output_name = format!("roundtrip.{extension}"); + let options = ExportOptions { force_psb: version.is_psb(), ..Default::default() }; + let saved = export(&native_open.document, &output_name, &options).expect("re-export PSD/PSB"); + let (_, output_block) = parsed_lookup_block(&saved.bytes); + assert_eq!(output_block, source_block, "saved block, {extension}, {depth}-bit, {domain:?}"); + let reopened = import(&output_name, &saved.bytes).expect("reopen re-exported PSD/PSB"); + assert_eq!(unsupported_lookup_raw(&reopened.document), source_block, "reopened block, {extension}, {depth}-bit"); + } + } +} + +#[test] +fn default_domain_lookup_stays_readable() { + for (version, domain, extension) in [(Version::Psd, "", "psd"), (Version::Psb, "DOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n", "psb")] { + let cube = identity_cube(domain); + let input = synthetic_lookup_psd(version, 8, &cube); + let opened = import(&format!("default.{extension}"), &input).expect("open default-domain LUT"); + let layer = opened.document.layers.iter().find(|layer| layer.name == "Custom-domain LUT").expect("lookup layer"); + assert!(matches!(layer.content, LayerContent::Adjustment(Adjustment::ColorLookup { size: 2, .. })), "{:?}", layer.content); + assert!(opened.warnings.iter().all(|warning| !warning.contains("Custom-domain LUT")), "{:?}", opened.warnings); + } +} diff --git a/crates/io/tests/affinity.rs b/crates/io/tests/it/affinity.rs similarity index 100% rename from crates/io/tests/affinity.rs rename to crates/io/tests/it/affinity.rs diff --git a/crates/io/tests/affinity_artboards.rs b/crates/io/tests/it/affinity_artboards.rs similarity index 98% rename from crates/io/tests/affinity_artboards.rs rename to crates/io/tests/it/affinity_artboards.rs index 67e3739043..26a9a3c7e5 100644 --- a/crates/io/tests/affinity_artboards.rs +++ b/crates/io/tests/it/affinity_artboards.rs @@ -1,7 +1,7 @@ //! Current .af artboards stay separate, editable and clipped through native/PSD saves. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -#[path = "support/current_affinity.rs"] +#[path = "../support/current_affinity.rs"] mod fixture; use fixture::{Marker, document}; diff --git a/crates/io/tests/affinity_corpus.rs b/crates/io/tests/it/affinity_corpus.rs similarity index 100% rename from crates/io/tests/affinity_corpus.rs rename to crates/io/tests/it/affinity_corpus.rs diff --git a/crates/io/tests/annotations.rs b/crates/io/tests/it/annotations.rs similarity index 100% rename from crates/io/tests/annotations.rs rename to crates/io/tests/it/annotations.rs diff --git a/crates/io/tests/artboard_guide_order.rs b/crates/io/tests/it/artboard_guide_order.rs similarity index 100% rename from crates/io/tests/artboard_guide_order.rs rename to crates/io/tests/it/artboard_guide_order.rs diff --git a/crates/io/tests/banded.rs b/crates/io/tests/it/banded.rs similarity index 100% rename from crates/io/tests/banded.rs rename to crates/io/tests/it/banded.rs diff --git a/crates/io/tests/it/blend_modes_corpus.rs b/crates/io/tests/it/blend_modes_corpus.rs new file mode 100644 index 0000000000..296e2e2a50 --- /dev/null +++ b/crates/io/tests/it/blend_modes_corpus.rs @@ -0,0 +1,67 @@ +//! Blend modes against Photoshop's own merged image of psd-tools `blend-modes/rgb-blend-modes.psd`: +//! one 64 × 64 cell per mode, a duck over a rainbow backdrop. +#![cfg(feature = "corpus")] + +use photocraft_color::BlendMode; +use photocraft_geom::Rect; +use photocraft_io::{import, merged_composite}; +use photocraft_psd::PsdFile; + +const CELL: i32 = 64; + +/// The largest and mean difference in 8-bit levels between our flatten and Photoshop's merged image +/// over the cell of `mode`, on the pixels where that cell's duck is fully opaque (its anti-aliased +/// edge mixes in the backdrop, which says nothing about the mode). +fn cell_error(mode: BlendMode) -> (f32, f32) { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/blend-modes/rgb-blend-modes.psd"); + let bytes = std::fs::read(path).expect("run cargo xtask corpus --psd-tools"); + 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 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"); + let bounds = surface.content_bounds(); + let (x0, y0) = ((bounds.x0 / CELL) * CELL, (bounds.y0 / CELL) * CELL); + let mut alpha = vec![[0.0f32; 4]; (CELL * CELL) as usize]; + surface.read_rgba_into(Rect::from_xywh(x0, y0, CELL as u32, CELL as u32), &mut alpha); + let (mut max, mut sum, mut n) = (0.0f32, 0.0f32, 0u32); + for y in 1..CELL - 1 { + for x in 1..CELL - 1 { + // Opaque, and so are its four neighbours (one pixel in from the edge). + let opaque = |dx: i32, dy: i32| alpha[((y + dy) * CELL + x + dx) as usize][3] >= 1.0; + if !(opaque(0, 0) && opaque(-1, 0) && opaque(1, 0) && opaque(0, -1) && opaque(0, 1)) { + continue; + } + let i = ((y0 + y) * width + x0 + x) as usize; + for c in 0..3 { + let d = ((ours[i][c] * 255.0).round() - (merged[i][c] * 255.0).round()).abs(); + max = max.max(d); + sum += d; + n += 1; + } + } + } + assert!(n > 3000, "{mode:?}: only {n} opaque samples"); + (max, sum / n as f32) +} + +/// #8804: Soft Light over the dark parts of the rainbow. With `sqrt(cb)` below a quarter the cell +/// was up to 20 levels too light; the W3C curve Photoshop uses is within 7. +#[test] +fn soft_light_matches_photoshop_over_dark_backdrops() { + let (max, mean) = cell_error(BlendMode::SoftLight); + eprintln!("Soft Light: max {max} mean {mean:.3}"); + assert!(max <= 7.0 && mean < 0.6, "max {max} mean {mean}"); +} + +/// The modes #8804 checked next to Soft Light stay where they were. +#[test] +fn separable_light_modes_match_photoshop() { + for mode in [BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay, BlendMode::HardLight] { + let (max, mean) = cell_error(mode); + eprintln!("{mode:?}: max {max} mean {mean:.3}"); + assert!(max <= 3.0, "{mode:?}: max {max} mean {mean}"); + } +} diff --git a/crates/io/tests/cancel.rs b/crates/io/tests/it/cancel.rs similarity index 100% rename from crates/io/tests/cancel.rs rename to crates/io/tests/it/cancel.rs diff --git a/crates/io/tests/channels.rs b/crates/io/tests/it/channels.rs similarity index 100% rename from crates/io/tests/channels.rs rename to crates/io/tests/it/channels.rs diff --git a/crates/io/tests/cmyk_composite_export.rs b/crates/io/tests/it/cmyk_composite_export.rs similarity index 98% rename from crates/io/tests/cmyk_composite_export.rs rename to crates/io/tests/it/cmyk_composite_export.rs index 31c0b6969f..83e6b37818 100644 --- a/crates/io/tests/cmyk_composite_export.rs +++ b/crates/io/tests/it/cmyk_composite_export.rs @@ -1,6 +1,6 @@ use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{ChannelLayout, SampleType as CodecSample, decode}; -use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; use photocraft_geom::{Rect, Size}; use photocraft_io::{ExportOptions, document_to_image, export}; @@ -223,6 +223,9 @@ fn composite_uses_distinctive_document_profile_on_every_worker() { let mut doc = document(SampleType::F32, 0.5); doc.size = Size::new(64, 64); doc.icc_profile = Some(profile.to_bytes()); + // Hue mixes channels, so the stack is composited in RGB and converted back through the + // profile (the empty layer leaves the composite as it is): the path under test. + doc.layers[1].blend = BlendMode::Hue; let rgb = photocraft_compose::flatten(&doc); let transform = Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true).unwrap(); let fallback = Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).unwrap(); diff --git a/crates/io/tests/it/cmyk_export.rs b/crates/io/tests/it/cmyk_export.rs new file mode 100644 index 0000000000..043da677f2 --- /dev/null +++ b/crates/io/tests/it/cmyk_export.rs @@ -0,0 +1,237 @@ +//! A CMYK document is written to flat formats as CMYK with its own profile whatever its layers +//! (#2220), mixing layers on their ink values so opaque pixels keep their exact inks. + +use std::sync::Arc; + +use photocraft_cms::Builtin; +use photocraft_codecs::ChannelLayout; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::*; + +fn cmyk_doc(depth: SampleType) -> Document { + let mut d = Document::new("c", Size::new(12, 8), ColorMode::Cmyk, depth); + d.icc_profile = Some(Arc::new(Builtin::CoatedCmyk.profile().to_bytes().to_vec())); + d +} + +fn fill(depth: SampleType, alpha: bool, rects: &[(Rect, [f32; 5])]) -> Layer { + let mut l = Layer::raster("l", PixelFormat::new(ColorMode::Cmyk, depth, alpha)); + if let Some(s) = l.surface_mut() { + for (r, c) in rects { + s.fill_rect(*r, &c[..if alpha { 5 } else { 4 }]); + } + } + l +} + +/// Paper (no ink), K-only black "type", and a four-colour patch, as separate layers. +fn layered(depth: SampleType) -> Document { + let mut d = cmyk_doc(depth); + d.layers.push(fill(depth, false, &[(Rect::new(0, 0, 12, 8), [0.0; 5])])); + d.layers.push(fill(depth, true, &[(Rect::new(0, 0, 6, 8), [0.0, 0.0, 0.0, 1.0, 1.0])])); + d.layers.push(fill(depth, true, &[(Rect::new(6, 0, 12, 4), [0.2, 0.4, 0.6, 0.1, 1.0])])); + d +} + +fn decode(bytes: &[u8]) -> photocraft_codecs::Image { + photocraft_codecs::decode(bytes).unwrap_or_else(|e| panic!("decode: {e}")) +} + +/// Normalized C, M, Y, K (and alpha) of pixel (x, y). +fn pixel(img: &photocraft_codecs::Image, x: u32, y: u32) -> Vec { + let n = img.layout().channels(); + let (w, _) = img.dimensions(); + let v = img.to_normalized(); + let i = ((y * w + x) as usize) * n; + v[i..i + n].to_vec() +} + +fn q8(v: f32) -> f32 { + (v * 255.0).round() / 255.0 +} + +#[test] +fn layered_cmyk_tiff_stays_cmyk_with_the_document_profile_and_exact_inks() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = layered(depth); + let r = export(&d, "a.tif", &ExportOptions::default()).unwrap_or_else(|e| panic!("{depth:?}: {e}")); + assert!(!r.warnings.iter().any(|w| w.contains("written as sRGB") || w.contains("converted back")), "{depth:?}: {:?}", r.warnings); + let img = decode(&r.bytes); + assert_eq!(img.layout(), ChannelLayout::Cmyk, "{depth:?}: opaque CMYK, no alpha"); + assert_eq!(img.icc.as_deref(), d.icc_profile.as_deref().map(|v| &v[..]), "{depth:?}: the document's own profile"); + // Exact inks: K-only black stays K-only, the four-colour patch keeps every ink, paper stays blank. + let (k, rich, paper) = (pixel(&img, 2, 3), pixel(&img, 8, 1), pixel(&img, 8, 6)); + let tol = if depth == SampleType::U8 { 0.0 } else { 1e-4 }; + let close = |a: &[f32], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol); + let want_rich = if depth == SampleType::U8 { [q8(0.2), q8(0.4), q8(0.6), q8(0.1)] } else { [0.2, 0.4, 0.6, 0.1] }; + assert!(close(&k, [0.0, 0.0, 0.0, 1.0]), "{depth:?}: {k:?}"); + assert!(close(&rich, want_rich), "{depth:?}: {rich:?}"); + assert!(close(&paper, [0.0; 4]), "{depth:?}: {paper:?}"); + // And it reads back as a CMYK document. + let back = import("a.tif", &r.bytes).unwrap_or_else(|e| panic!("{e}")).document; + assert_eq!(back.mode, ColorMode::Cmyk, "{depth:?}"); + } +} + +#[test] +fn the_reported_case_an_empty_extra_layer() { + // #2220: one layer converted to CMYK plus an empty layer used to export as RGB. + let mut d = cmyk_doc(SampleType::U8); + d.layers.push(fill(SampleType::U8, false, &[(Rect::new(0, 0, 12, 8), [0.3, 0.2, 0.1, 0.0, 1.0])])); + d.layers.push(Layer::raster("empty", PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true))); + for ext in ["tif", "jpg"] { + let r = export(&d, &format!("a.{ext}"), &ExportOptions::default()).unwrap_or_else(|e| panic!("{ext}: {e}")); + assert_eq!(decode(&r.bytes).layout(), ChannelLayout::Cmyk, "{ext}"); + } +} + +#[test] +fn transparency_is_kept_in_tiff_and_composited_over_paper_in_jpeg() { + let mut d = cmyk_doc(SampleType::U8); + d.layers.push(fill(SampleType::U8, true, &[(Rect::new(0, 0, 6, 8), [0.0, 0.0, 0.0, 1.0, 1.0])])); + d.layers.push(fill(SampleType::U8, true, &[(Rect::new(6, 0, 12, 4), [0.0, 1.0, 0.0, 0.0, 0.5])])); + let tif = decode(&export(&d, "a.tif", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")).bytes); + assert_eq!(tif.layout(), ChannelLayout::CmykA); + assert_eq!(pixel(&tif, 8, 1), vec![0.0, 1.0, 0.0, 0.0, q8(0.5)], "straight ink and coverage"); + assert_eq!(pixel(&tif, 8, 6)[4], 0.0, "uncovered stays transparent"); + let r = export(&d, "a.jpg", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); + let jpg = decode(&r.bytes); + assert_eq!(jpg.layout(), ChannelLayout::Cmyk, "{:?}", r.warnings); + // JPEG is lossy: half-covered magenta over blank paper is about M50; uncovered paper has no ink. + let (half, blank, k) = (pixel(&jpg, 9, 1), pixel(&jpg, 9, 6), pixel(&jpg, 2, 3)); + assert!((half[1] - 0.5).abs() < 0.06 && half[0] < 0.06 && half[3] < 0.06, "{half:?}"); + assert!(blank.iter().all(|v| *v < 0.06), "{blank:?}"); + assert!(k[3] > 0.94 && k[..3].iter().all(|v| *v < 0.06), "{k:?}"); +} + +#[test] +fn multiply_adds_inks_exactly() { + // Multiply darkens: each ink of the result is b + s − b·s, with no ink the layers don't have. + let mut d = layered(SampleType::U16); + let mut m = fill(SampleType::U16, true, &[(Rect::new(0, 0, 12, 8), [0.0, 0.5, 0.0, 0.0, 1.0])]); + m.blend = BlendMode::Multiply; + d.layers.push(m); + let r = export(&d, "a.tif", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); + assert!(!r.warnings.iter().any(|w| w.contains("converted back")), "{:?}", r.warnings); + let img = decode(&r.bytes); + assert_eq!(img.layout(), ChannelLayout::Cmyk); + let mul = |b: f32, s: f32| b + s - b * s; + let close = |a: &[f32], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-4); + let (k, rich, paper) = (pixel(&img, 2, 3), pixel(&img, 8, 1), pixel(&img, 8, 6)); + assert!(close(&k, [0.0, 0.5, 0.0, 1.0]), "{k:?}"); + assert!(close(&rich, [0.2, mul(0.4, 0.5), 0.6, 0.1]), "{rich:?}"); + assert!(close(&paper, [0.0, 0.5, 0.0, 0.0]), "{paper:?}"); +} + +#[test] +fn other_stacks_stay_cmyk_through_the_profile() { + // Hue mixes channels, so it isn't composited on ink values: the RGB composite is converted back. + let mut d = layered(SampleType::U8); + let mut m = fill(SampleType::U8, true, &[(Rect::new(0, 0, 12, 8), [0.0, 0.5, 0.0, 0.0, 1.0])]); + m.blend = BlendMode::Hue; + d.layers.push(m); + let r = export(&d, "a.tif", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); + assert!(r.warnings.iter().any(|w| w.contains("converted back through the document's CMYK profile")), "{:?}", r.warnings); + let img = decode(&r.bytes); + assert_eq!(img.layout(), ChannelLayout::Cmyk); + assert_eq!(img.icc.as_deref(), d.icc_profile.as_deref().map(|v| &v[..])); + // Magenta's hue on the four-colour patch shifts its colour; the page still has ink. + assert!(pixel(&img, 2, 3)[3] > 0.5, "{:?}", pixel(&img, 2, 3)); +} + +#[test] +fn rgb_only_formats_still_get_rgb_through_the_profile() { + let r = export(&layered(SampleType::U8), "a.png", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); + let img = decode(&r.bytes); + assert!(matches!(img.layout(), ChannelLayout::Rgb | ChannelLayout::Rgba), "{:?}", img.layout()); + assert!(r.warnings.iter().any(|w| w.contains("Cmyk composite written as sRGB RGB")), "{:?}", r.warnings); +} + +#[test] +fn ink_adjustments_export_exact_inks() { + // Invert in CMYK turns every ink into its opposite: blank paper becomes four-colour black, + // K-only black loses its K and gains full C, M and Y. + let mut d = layered(SampleType::U8); + d.layers.push(Layer::new("inv", photocraft_doc::LayerContent::Adjustment(photocraft_doc::Adjustment::Invert))); + let r = export(&d, "a.tif", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); + assert!(!r.warnings.iter().any(|w| w.contains("converted back")), "{:?}", r.warnings); + let img = decode(&r.bytes); + assert_eq!(img.layout(), ChannelLayout::Cmyk); + assert_eq!(pixel(&img, 8, 6), vec![1.0, 1.0, 1.0, 1.0], "paper"); + assert_eq!(pixel(&img, 2, 3), vec![1.0, 1.0, 1.0, 0.0], "K-only black"); + let inv = |v: f32| (255.0 - (v * 255.0).round()) / 255.0; + assert_eq!(pixel(&img, 8, 1), vec![inv(0.2), inv(0.4), inv(0.6), inv(0.1)], "four-colour patch"); +} + +#[test] +fn channel_weighing_adjustments_go_through_the_profile() { + // Threshold weighs the channels together: it isn't an ink-by-ink edit. + let mut d = layered(SampleType::U8); + d.layers.push(Layer::new("t", photocraft_doc::LayerContent::Adjustment(photocraft_doc::Adjustment::Threshold { level: 0.5 }))); + let r = export(&d, "a.tif", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); + assert!(r.warnings.iter().any(|w| w.contains("converted back through the document's CMYK profile")), "{:?}", r.warnings); + assert_eq!(decode(&r.bytes).layout(), ChannelLayout::Cmyk); +} + +#[test] +fn layer_effects_export_their_inks() { + // K-only type with a K-only drop shadow on blank paper: the TIFF has black ink only. + use photocraft_doc::effects::{Contour, Effect, FxCommon, Shadow}; + let mut d = cmyk_doc(SampleType::U8); + d.layers.push(fill(SampleType::U8, false, &[(Rect::new(0, 0, 12, 8), [0.0; 5])])); + let mut t = fill(SampleType::U8, true, &[(Rect::new(2, 2, 6, 5), [0.0, 0.0, 0.0, 1.0, 1.0])]); + t.effects.enabled = true; + t.effects.items.push(Effect::DropShadow(Shadow { + common: FxCommon::new(BlendMode::Multiply, 0.75), + color: photocraft_color::Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 }, + angle: 135.0, + use_global_light: false, + distance: 2.0, + spread: 0.0, + size: 1.0, + contour: Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + })); + d.layers.push(t); + let r = export(&d, "a.tif", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); + assert!(!r.warnings.iter().any(|w| w.contains("converted back")), "{:?}", r.warnings); + let img = decode(&r.bytes); + assert_eq!(img.layout(), ChannelLayout::Cmyk); + let mut shadow = 0; + for y in 0..8 { + for x in 0..12 { + let p = pixel(&img, x, y); + assert_eq!(&p[..3], &[0.0; 3], "({x},{y}) {p:?}"); + shadow += usize::from(p[3] > 0.0 && p[3] < 1.0); + } + } + assert!(shadow > 0, "a soft shadow"); + assert_eq!(pixel(&img, 3, 3), vec![0.0, 0.0, 0.0, 1.0], "type"); +} + +#[test] +fn psd_merged_image_keeps_exact_inks() { + // The PSD's merged image (what other apps show and print) is mixed on the inks too: K-only + // black under a Multiply layer stays K-only, and the four-colour patch keeps its inks. + let mut d = layered(SampleType::U8); + let mut m = fill(SampleType::U8, true, &[(Rect::new(0, 0, 12, 8), [0.0, 0.4, 0.0, 0.0, 1.0])]); + m.blend = BlendMode::Multiply; + d.layers.push(m); + let r = export(&d, "a.psd", &ExportOptions::default()).unwrap_or_else(|e| panic!("{e}")); + let f = photocraft_psd::PsdFile::from_bytes(&r.bytes).unwrap_or_else(|e| panic!("{e}")); + let planes = f.decode_merged().unwrap_or_else(|e| panic!("{e}")); + let n = 12 * 8; + // Planar, inverted (255 = no ink). + let ink = |c: usize, x: usize, y: usize| 255 - i32::from(planes.get(c * n + y * 12 + x).copied().unwrap_or(0)); + let q = |v: f32| (v * 255.0).round() as i32; + assert_eq!([ink(0, 2, 3), ink(1, 2, 3), ink(2, 2, 3), ink(3, 2, 3)], [0, q(0.4), 0, 255], "K-only black with magenta multiplied in"); + let mul = |b: f32, s: f32| b + s - b * s; + let qb = |v: f32| ((v * 255.0).round()) / 255.0; + let want = [q(0.2), q(mul(qb(0.4), qb(0.4))), q(0.6), q(0.1)]; + let got = [ink(0, 8, 1), ink(1, 8, 1), ink(2, 8, 1), ink(3, 8, 1)]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "{got:?} vs {want:?}"); +} diff --git a/crates/io/tests/color.rs b/crates/io/tests/it/color.rs similarity index 99% rename from crates/io/tests/color.rs rename to crates/io/tests/it/color.rs index c5d4479904..06b5134e17 100644 --- a/crates/io/tests/color.rs +++ b/crates/io/tests/it/color.rs @@ -1,7 +1,7 @@ //! ICC profiles through import/export: byte-exact PSD round trips with real profiles, PNG/JPEG //! embedding and extraction, colour-managed CMYK → RGB for formats without CMYK. -mod common; +use crate::common; use std::sync::Arc; @@ -234,7 +234,7 @@ fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() { } let fmt = doc.pixel_format(); doc.layers.push(raster("Background", fmt, doc.bounds(), 3, false)); - doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.clone() }))); + doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.as_slice().into() }))); let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; let mut back = import("x.psd", &bytes).unwrap().document; let LayerContent::Adjustment(Adjustment::GradientMap { stops: got, .. }) = &back.layers[1].content else { diff --git a/crates/io/tests/common/mod.rs b/crates/io/tests/it/common/mod.rs similarity index 99% rename from crates/io/tests/common/mod.rs rename to crates/io/tests/it/common/mod.rs index 28c1a01906..c964b08b82 100644 --- a/crates/io/tests/common/mod.rs +++ b/crates/io/tests/it/common/mod.rs @@ -172,7 +172,7 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document { black: Vec::new(), pencil: false, }, - Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] }, + Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0].into() }, ]; // Colour adjustments (Photoshop offers them for colour documents only: they would tint // a grayscale composite). diff --git a/crates/io/tests/composite.rs b/crates/io/tests/it/composite.rs similarity index 99% rename from crates/io/tests/composite.rs rename to crates/io/tests/it/composite.rs index fbb9e5ab3d..cef80e6a08 100644 --- a/crates/io/tests/composite.rs +++ b/crates/io/tests/it/composite.rs @@ -1,7 +1,7 @@ //! Composite oracle: the merged image stored in a PSD must match our //! compositor's rendering of the imported document. -mod common; +use crate::common; use common::*; use photocraft_color::{BlendMode, ColorMode, SampleType}; diff --git a/crates/io/tests/comps_artboards.rs b/crates/io/tests/it/comps_artboards.rs similarity index 100% rename from crates/io/tests/comps_artboards.rs rename to crates/io/tests/it/comps_artboards.rs diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/it/corpus.rs similarity index 99% rename from crates/io/tests/corpus.rs rename to crates/io/tests/it/corpus.rs index 710b2a8644..182d60095a 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/it/corpus.rs @@ -44,7 +44,7 @@ //! asserts import + flatten return (Ok or Err) without panicking. #![cfg(feature = "corpus")] -mod common; +use crate::common; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; @@ -154,9 +154,9 @@ const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_dept /// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7). const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 249, roundtrip_floor: 308 }; -/// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), +/// Our Photoshop-authored oracles (258 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 188, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 190, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/crates/io/tests/corpus_regressions.rs b/crates/io/tests/it/corpus_regressions.rs similarity index 99% rename from crates/io/tests/corpus_regressions.rs rename to crates/io/tests/it/corpus_regressions.rs index b335642cc9..26266c9f07 100644 --- a/crates/io/tests/corpus_regressions.rs +++ b/crates/io/tests/it/corpus_regressions.rs @@ -1,5 +1,5 @@ //! Synthetic reproductions of failures found by the psd-tools corpus -//! (`cargo xtask corpus --psd-tools`; see `tests/corpus.rs`). The corpus +//! (`cargo xtask corpus --psd-tools`; see `tests/it/corpus.rs`). The corpus //! files themselves are never committed: each test rebuilds the relevant //! structure from our own documents. diff --git a/crates/io/tests/it/cylinder_warp.rs b/crates/io/tests/it/cylinder_warp.rs new file mode 100644 index 0000000000..8ed1caa916 --- /dev/null +++ b/crates/io/tests/it/cylinder_warp.rs @@ -0,0 +1,123 @@ +//! Smart objects warped with Photoshop's Cylinder (#4112) through PSD persistence and a re-render +//! from their contents, as Replace Contents, Relink and Edit Contents do. + +use std::sync::Arc; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource}; +use photocraft_geom::warp::{Warp, WarpStyle}; +use photocraft_geom::{Affine, Rect, Size}; +use photocraft_io::{ExportOptions, export, import, smart_map}; +use photocraft_psd::PsdFile; +use photocraft_raster::Surface; + +/// Silhouettes at x 10 and 90, edges at y 20 and 80, ellipses 0.3 deep, wrapped as little as +/// Photoshop allows (a sixth of the contents on the back on each side). +const VALUES: [f64; 7] = [10.0, 80.0, 90.0, 20.0, 0.3, 0.3, 0.0]; + +/// A 100 × 100 source: a red band at the left and a blue band at the right (both on the back of +/// the cylinder), green in between. +fn contents() -> Surface { + let mut s = Surface::new(photocraft_color::PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + s.fill_rect(Rect::new(0, 0, 100, 100), &[0.0, 1.0, 0.0, 1.0]); + s.fill_rect(Rect::new(0, 0, 5, 100), &[1.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(95, 0, 100, 100), &[0.0, 0.0, 1.0, 1.0]); + s +} + +fn document(transform: Affine, perspective: Option<[f64; 9]>) -> (Document, Warp) { + let pixels = contents(); + let mut source = Document::new("label", Size::new(100, 100), ColorMode::Rgb, SampleType::U8); + source.layers.push(Layer::new("art", LayerContent::Raster(pixels.clone()))); + let bytes = photocraft_format::save_to_bytes(&source, &Default::default()).unwrap(); + let warp = Warp::cylinder(&VALUES, [0.0, 0.0, 100.0, 100.0]).unwrap(); + let mut sm = SmartObject::new(SmartSource::Embedded { file_name: "label.pcraft".into(), bytes: Arc::new(bytes) }, transform, None); + sm.warp = Some(warp.clone()); + sm.perspective = perspective; + sm.cache = Some(render(&sm)); + let mut doc = Document::new("jar", Size::new(160, 160), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new("label", LayerContent::Smart(sm))); + (doc, warp) +} + +/// The smart object drawn from its contents, as the engine re-renders it. +fn render(sm: &SmartObject) -> Surface { + let src = contents(); + match &sm.perspective { + Some(p) => { + photocraft_algo::warp::place_source_projective(&src, Rect::new(0, 0, 100, 100), &photocraft_algo::transform::Homography(*p), sm.warp.as_ref()) + } + None => photocraft_algo::warp::place_source(&src, Rect::new(0, 0, 100, 100), &sm.transform, sm.warp.as_ref()), + } + .unwrap() +} + +fn smart(doc: &Document) -> &SmartObject { + let LayerContent::Smart(sm) = &doc.layers[0].content else { panic!("not a smart object") }; + sm +} + +fn round_trip(doc: &Document) -> (PsdFile, Document) { + let out = export(doc, "psd", &ExportOptions::default()).unwrap(); + assert!(!out.warnings.iter().any(|w| w.contains("warp") || w.contains("written as pixels")), "{:?}", out.warnings); + (PsdFile::from_bytes(&out.bytes).unwrap(), import("psd", &out.bytes).unwrap().document) +} + +/// Opaque pixels of the render that are mostly `channel` (0 red, 1 green, 2 blue). +fn count(s: &Surface, channel: usize) -> usize { + let r = s.content_bounds(); + s.read_region(r).as_chunks::<4>().0.iter().filter(|p| p[3] > 0.5 && p[channel] > 0.5 && (0..3).all(|c| c == channel || p[c] < 0.5)).count() +} + +/// Before #4112 a cylinder was read as no warp: re-rendering the contents drew them flat, the +/// red and blue bands that wrap round the back included. Now the PSD keeps the cylinder and the +/// re-render hides the back and centres the front between the silhouettes. +#[test] +fn a_cylinder_survives_psd_and_wraps_the_contents_again() { + let transform = Affine { m: [1.0, 0.0, 0.0, 1.0, 30.0, 20.0] }; + let (doc, warp) = document(transform, None); + let (file, back) = round_trip(&doc); + let layer = file.layers().iter().find(|l| l.name() == "label").unwrap(); + let sold = smart_map::parse_sold(&layer.block(b"SoLd").unwrap().data).unwrap(); + let Some(photocraft_psd::descriptor::Value::Descriptor(w)) = sold.descriptor.get("warp") else { panic!("no warp") }; + assert!(matches!(w.get("warpStyle"), Some(photocraft_psd::descriptor::Value::Enumerated { value, .. }) if value.as_bytes() == b"warpCylinder")); + let sm = smart(&back); + assert_eq!(sm.warp.as_ref().map(|w| w.style), Some(WarpStyle::Cylinder)); + assert_eq!(sm.warp.as_ref(), Some(&warp)); + for (a, b) in sm.transform.m.iter().zip(transform.m) { + assert!((a - b).abs() < 1e-9, "{:?}", sm.transform); + } + let out = render(sm); + assert_eq!((count(&out, 0), count(&out, 2)), (0, 0), "the bands on the back are hidden"); + assert!(count(&out, 1) > 3000, "the front is drawn"); + // Between the silhouettes (x 10 and 90, shifted by 30) and no further. + let b = out.content_bounds(); + assert!(b.x0 >= 39 && b.x1 <= 121, "{b:?}"); + // The middle of the contents is at the front, centred. + let mid = out.pixel(80, 60); + assert!(mid[1] > 0.9 && mid[3] > 0.9, "{mid:?}"); + // Saved again unchanged, the block is written as it was read. + let (again, _) = round_trip(&back); + let layer2 = again.layers().iter().find(|l| l.name() == "label").unwrap(); + assert_eq!(layer2.block(b"SoLd").unwrap().data, layer.block(b"SoLd").unwrap().data); +} + +/// Mockups usually place the label in perspective (`nonAffineTransform`): that placement and +/// the cylinder both come back. +#[test] +fn a_cylinder_in_perspective_survives_psd() { + let cb = Warp::cylinder(&VALUES, [0.0, 0.0, 100.0, 100.0]).unwrap().mesh.unwrap().control_bounds(); + let h = photocraft_algo::transform::Homography::rect_to_quad(cb, [[20.0, 20.0], [140.0, 30.0], [135.0, 150.0], [25.0, 140.0]]).unwrap(); + let (doc, warp) = document(Affine::IDENTITY, Some(h.0)); + let (_, back) = round_trip(&doc); + let sm = smart(&back); + assert_eq!(sm.warp.as_ref(), Some(&warp)); + let p = sm.perspective.expect("still projective"); + let n = |m: [f64; 9]| m.map(|v| v / m[8]); + for (a, b) in n(p).iter().zip(n(h.0)) { + assert!((a - b).abs() < 1e-9 * (1.0 + b.abs()), "{p:?} vs {:?}", h.0); + } + let out = render(sm); + assert_eq!((count(&out, 0), count(&out, 2)), (0, 0)); + assert!(count(&out, 1) > 3000); +} diff --git a/crates/io/tests/effects_multi.rs b/crates/io/tests/it/effects_multi.rs similarity index 100% rename from crates/io/tests/effects_multi.rs rename to crates/io/tests/it/effects_multi.rs diff --git a/crates/io/tests/exif_resolution.rs b/crates/io/tests/it/exif_resolution.rs similarity index 100% rename from crates/io/tests/exif_resolution.rs rename to crates/io/tests/it/exif_resolution.rs diff --git a/crates/io/tests/export_progress.rs b/crates/io/tests/it/export_progress.rs similarity index 100% rename from crates/io/tests/export_progress.rs rename to crates/io/tests/it/export_progress.rs diff --git a/crates/io/tests/exr_part_profile.rs b/crates/io/tests/it/exr_part_profile.rs similarity index 100% rename from crates/io/tests/exr_part_profile.rs rename to crates/io/tests/it/exr_part_profile.rs diff --git a/crates/io/tests/flat.rs b/crates/io/tests/it/flat.rs similarity index 96% rename from crates/io/tests/flat.rs rename to crates/io/tests/it/flat.rs index 72126d0bc0..f6e888eb06 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/it/flat.rs @@ -1,6 +1,6 @@ //! Flat formats through photocraft-codecs, and export warnings. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; @@ -56,6 +56,10 @@ codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0); codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0); +// Cineon stores 10-bit code values: within half a 10-bit step. +codec_rt!(cin_gray16, "cin", ColorMode::Grayscale, SampleType::U16, false, 0.5 / 1023.0); +codec_rt!(cin_rgb16, "cin", ColorMode::Rgb, SampleType::U16, false, 0.5 / 1023.0); +codec_rt!(iff_rgb8_opaque, "iff", ColorMode::Rgb, SampleType::U8, false, 0.0); fn indexed_bmp_8() -> Vec { const FILE_HEADER_SIZE: usize = 14; @@ -1173,7 +1177,7 @@ fn a_lab_document_with_one_layer_is_not_reported_as_flattened() { fn a_stereo_png_keeps_its_stereo_mark_on_save() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, PngStereo, SampleType as CS}; let img = Image::from_raw(4, 2, ChannelLayout::Rgb, CS::U8, vec![40u8; 24]).unwrap(); - for view in [PngStereo::Horizontal, PngStereo::Vertical] { + for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] { let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions { png_stereo: Some(view), ..Default::default() }).unwrap(); let doc = import("stereo.png", &bytes).unwrap().document; assert_eq!(doc.file_encoding.png_stereo, Some(view.mode())); @@ -1187,3 +1191,39 @@ fn a_stereo_png_keeps_its_stereo_mark_on_save() { let plain = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); assert_eq!(photocraft_codecs::png_stereo(&plain.bytes), None); } + +/// #8563: a DDS export writes the document's alpha channel as the texture's alpha, as a 32-bit +/// Targa does: exactly in an uncompressed texture, to 4 bits in BC2, interpolated in BC3, and on +/// or off in BC1. With several alpha channels none is written, with a warning. +#[test] +fn dds_writes_the_alpha_channel_as_the_texture_alpha() { + use photocraft_codecs::DdsCompression; + let d = with_alpha_channel(single(ColorMode::Rgb, SampleType::U8, false), "Alpha 1"); + for compression in DdsCompression::ALL { + let mut opts = ExportOptions::default(); + opts.encode.dds.compression = compression; + let r = export(&d, "a.dds", &opts).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).expect("decode dds"); + assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba, "{compression:?}"); + let (w, _) = img.dimensions(); + let tolerance = match compression { + DdsCompression::None => 1.0, + DdsCompression::Bc2 => 9.0, + _ => 18.0, + }; + for (i, px) in img.data().as_chunks::<4>().0.iter().enumerate() { + let x = i as u32 % w; + let want = alpha_value(x) * 255.0; + if compression == DdsCompression::Bc1 && x == 4 { + continue; // half opacity sits on BC1's on/off threshold + } + assert!((f32::from(px[3]) - want).abs() <= tolerance, "{compression:?} pixel {i}: alpha {} want {want}", px[3]); + } + } + // Two alpha channels: Photoshop's rule for Targa, none of them is written. + let two = with_alpha_channel(d.clone(), "Alpha 2"); + let r = export(&two, "a.dds", &ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("several")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).expect("decode dds"); + assert!(!img.layout().has_alpha() || img.data().as_chunks::<4>().0.iter().all(|p| p[3] == 255)); +} diff --git a/crates/io/tests/gif_frames.rs b/crates/io/tests/it/gif_frames.rs similarity index 100% rename from crates/io/tests/gif_frames.rs rename to crates/io/tests/it/gif_frames.rs diff --git a/crates/io/tests/guides_v2.rs b/crates/io/tests/it/guides_v2.rs similarity index 100% rename from crates/io/tests/guides_v2.rs rename to crates/io/tests/it/guides_v2.rs diff --git a/crates/io/tests/heif.rs b/crates/io/tests/it/heif.rs similarity index 100% rename from crates/io/tests/heif.rs rename to crates/io/tests/it/heif.rs diff --git a/crates/io/tests/heif_unsupported.rs b/crates/io/tests/it/heif_unsupported.rs similarity index 100% rename from crates/io/tests/heif_unsupported.rs rename to crates/io/tests/it/heif_unsupported.rs diff --git a/crates/io/tests/krita_corpus.rs b/crates/io/tests/it/krita_corpus.rs similarity index 97% rename from crates/io/tests/krita_corpus.rs rename to crates/io/tests/it/krita_corpus.rs index b0b3a01d63..ab2c56c143 100644 --- a/crates/io/tests/krita_corpus.rs +++ b/crates/io/tests/it/krita_corpus.rs @@ -2,7 +2,7 @@ //! Krita's own rendering of the layer stack (`mergedimage.png`). The files are generated by //! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus and are //! not bundled. Run with -//! `KRITA_TEST_DIR= cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture`. +//! `KRITA_TEST_DIR= cargo test -p photocraft-io --test it -- krita_corpus:: --ignored --nocapture`. use std::path::{Path, PathBuf}; diff --git a/crates/io/tests/lab16.rs b/crates/io/tests/it/lab16.rs similarity index 100% rename from crates/io/tests/lab16.rs rename to crates/io/tests/it/lab16.rs diff --git a/crates/io/tests/large_files.rs b/crates/io/tests/it/large_files.rs similarity index 98% rename from crates/io/tests/large_files.rs rename to crates/io/tests/it/large_files.rs index f492dd2d65..b0a2669480 100644 --- a/crates/io/tests/large_files.rs +++ b/crates/io/tests/it/large_files.rs @@ -5,7 +5,7 @@ //! //! The two layered documents run in memory; the streamed files are written to //! `$PHOTOCRAFT_FIXTURE_DIR` (default: the system temp dir), removed afterwards, and are opt-in: -//! `cargo test --release -p photocraft-io --test large_files -- --ignored`. +//! `cargo test --release -p photocraft-io --test it -- large_files:: --ignored`. use std::path::PathBuf; diff --git a/crates/io/tests/large_psb.rs b/crates/io/tests/it/large_psb.rs similarity index 96% rename from crates/io/tests/large_psb.rs rename to crates/io/tests/it/large_psb.rs index bcde955bd0..da9f6ca9a1 100644 --- a/crates/io/tests/large_psb.rs +++ b/crates/io/tests/it/large_psb.rs @@ -25,7 +25,7 @@ fn undecodable_channel_leaves_the_layer_empty() { /// A flattened 29000² RGB PSB: its merged image decodes to 2.52 GB, past the old 2 GiB cap, which /// opened it as a document with no layers. Needs about 12 GB of memory, so it is opt-in: -/// `cargo test --release -p photocraft-io --test large_psb -- --ignored`. +/// `cargo test --release -p photocraft-io --test it -- large_psb:: --ignored`. #[test] #[ignore = "allocates ~12 GB; run in release"] fn flattened_psb_with_a_merged_image_over_2_gib_opens() { diff --git a/crates/io/tests/layer_labels.rs b/crates/io/tests/it/layer_labels.rs similarity index 100% rename from crates/io/tests/layer_labels.rs rename to crates/io/tests/it/layer_labels.rs diff --git a/crates/io/tests/it/layer_xmp.rs b/crates/io/tests/it/layer_xmp.rs new file mode 100644 index 0000000000..87a02b0876 --- /dev/null +++ b/crates/io/tests/it/layer_xmp.rs @@ -0,0 +1,102 @@ +//! A layer's own XMP packet (`Layer::xmp`, PSD `shmd` › `cust` › `layerXMP`) survives every +//! layered format and keeps the rest of a Photoshop `shmd` block as it was. + +use std::sync::Arc; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, Size}; +use photocraft_io::{ExportOptions, export, import}; +use photocraft_psd::PsdFile; +use photocraft_psd::metadata::{layer_xmp, put_layer_xmp}; + +const A: &str = r#"120"#; +const G: &str = r#"Café 🎨"#; + +/// `shmd` of a Photoshop 2026 layer without XMP: a `cust` item holding only `layerTime`. +const PS_WITHOUT_XMP: &str = + "000000013842494d63757374000000000000003400000010000000010000000000086d6574616461746100000001000000096c6179657254696d65646f756241dab643ffd86bec00"; + +fn unhex(h: &str) -> Vec { + (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect() +} + +fn xmps(d: &Document) -> Vec<(String, Option)> { + d.walk().into_iter().map(|(_, _, l)| (l.name.clone(), l.xmp.as_deref().map(str::to_string))).collect() +} + +fn layer_shmd<'a>(f: &'a PsdFile, name: &str) -> Option<&'a [u8]> { + f.layers().iter().find(|r| r.name() == name).and_then(|r| r.block(b"shmd")).map(|b| b.data.as_slice()) +} + +fn sample(depth: SampleType) -> Document { + let mut doc = Document::new("Layer XMP", Size::new(4, 4), ColorMode::Rgb, depth); + let mut a = Layer::raster("A", doc.pixel_format()); + a.xmp = Some(Arc::from(A)); + let plain = Layer::raster("Plain", doc.pixel_format()); + let mut g = Layer::group("G", vec![plain]); + g.xmp = Some(Arc::from(G)); + doc.layers.extend([a, g]); + doc +} + +#[test] +fn layer_xmp_round_trips_through_every_layered_format_and_depth() { + let psb = ExportOptions { force_psb: true, ..Default::default() }; + let tiff = ExportOptions { tiff_layers: true, ..Default::default() }; + for depth in SampleType::ALL { + let doc = sample(depth); + for (path, opts) in [("x.psd", &ExportOptions::default()), ("x.psb", &psb), ("x.tif", &tiff), ("x.pcraft", &ExportOptions::default())] { + let bytes = export(&doc, path, opts).unwrap().bytes; + let back = import(path, &bytes).unwrap().document; + assert_eq!(xmps(&back), xmps(&doc), "{path} {depth:?}"); + } + // Stored as Photoshop stores it; a layer without XMP gets no block. + let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + let f = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(layer_shmd(&f, "A").and_then(layer_xmp).as_deref(), Some(A)); + assert_eq!(layer_shmd(&f, "G").and_then(layer_xmp).as_deref(), Some(G)); + assert!(layer_shmd(&f, "Plain").is_none()); + } +} + +#[test] +fn photoshop_shmd_keeps_its_other_data() { + let without = unhex(PS_WITHOUT_XMP); + let with = put_layer_xmp(Some(&without), A).unwrap(); + // A layer as an older PhotoCraft kept it: Photoshop's block verbatim, no field. + let mut doc = Document::new("PS", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + let mut l = Layer::raster("L", doc.pixel_format()); + l.psd_blocks.push((*b"shmd", Arc::new(with.clone()))); + doc.layers.push(l); + let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + + // Opening it gives the packet to the field and keeps the rest of the block. + let mut back = import("x.psd", &bytes).unwrap().document; + let l = &back.layers[0]; + assert_eq!(l.xmp.as_deref(), Some(A)); + assert_eq!(l.psd_blocks.iter().find(|(k, _)| k == b"shmd").map(|(_, d)| d.as_slice()), Some(without.as_slice())); + + // Unchanged, the block is written back byte for byte. + let again = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(layer_shmd(&PsdFile::from_bytes(&again).unwrap(), "L"), Some(with.as_slice())); + + // Edited, `layerTime` stays; removed, the block is Photoshop's block without XMP. + back.layers[0].xmp = Some(Arc::from(G)); + let edited = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(layer_shmd(&PsdFile::from_bytes(&edited).unwrap(), "L"), Some(put_layer_xmp(Some(&without), G).unwrap().as_slice())); + back.layers[0].xmp = None; + let removed = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(layer_shmd(&PsdFile::from_bytes(&removed).unwrap(), "L"), Some(without.as_slice())); +} + +#[test] +fn an_unreadable_shmd_is_kept_and_reported() { + let mut doc = Document::new("Bad", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + let mut l = Layer::raster("L", doc.pixel_format()); + l.psd_blocks.push((*b"shmd", Arc::new(vec![0, 0, 0, 9]))); + l.xmp = Some(Arc::from(A)); + doc.layers.push(l); + let out = export(&doc, "x.psd", &ExportOptions::default()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("XMP metadata was not saved")), "{:?}", out.warnings); + assert_eq!(layer_shmd(&PsdFile::from_bytes(&out.bytes).unwrap(), "L"), Some([0, 0, 0, 9].as_slice())); +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs new file mode 100644 index 0000000000..5e68cd172f --- /dev/null +++ b/crates/io/tests/it/main.rs @@ -0,0 +1,81 @@ +//! The crate's integration tests, linked into one test binary. + +mod adjust_blend_roundtrip; +mod adjust_warnings; +mod affinity; +mod affinity_artboards; +mod affinity_corpus; +mod annotations; +mod artboard_guide_order; +mod banded; +mod blend_modes_corpus; +mod cancel; +mod channels; +mod cmyk_composite_export; +mod cmyk_export; +mod color; +mod common; +mod composite; +mod comps_artboards; +mod corpus; +mod corpus_regressions; +mod cylinder_warp; +mod effects_multi; +mod exif_resolution; +mod export_progress; +mod exr_part_profile; +mod flat; +mod gif_frames; +mod guides_v2; +mod heif; +mod heif_unsupported; +mod krita_corpus; +mod lab16; +mod large_files; +mod large_psb; +mod layer_labels; +mod layer_xmp; +mod modes; +mod multichannel; +mod native_vector_preview; +mod orientation; +mod pattern_fill_psd; +mod pcraft_manifest_name; +mod pdn_corpus; +mod png_text_metadata; +mod psd16_merged_transparency; +mod psd32_export; +mod psd_background; +mod psd_block_alignment; +mod psd_block_layout; +mod psd_damaged_merged; +mod psd_empty_channels; +mod psd_font_identity; +mod psd_kerning; +mod psd_layer_block_length; +mod psd_maximize_compatibility; +mod psd_noise_fill_roundtrip; +mod psd_recovery_generated; +mod psd_roundtrip_bytes; +mod psd_structure; +mod psd_style_sheets; +mod psd_thumbnail; +mod psd_type_bounds; +mod psd_type_photoshop_bytes; +mod psd_unknown_adjustment; +mod psd_vertical_type; +mod raw; +mod resolution_axes; +mod roundtrip; +mod smart_contents_identity; +mod smart_corpus; +mod svg; +mod text_corpus; +mod text_styles; +mod tiff_corpus; +mod tiff_layers; +mod tiff_pages; +mod tiff_type_geometry; +mod vector; +mod xcf; +mod xmp_export; diff --git a/crates/io/tests/modes.rs b/crates/io/tests/it/modes.rs similarity index 100% rename from crates/io/tests/modes.rs rename to crates/io/tests/it/modes.rs diff --git a/crates/io/tests/multichannel.rs b/crates/io/tests/it/multichannel.rs similarity index 100% rename from crates/io/tests/multichannel.rs rename to crates/io/tests/it/multichannel.rs 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/orientation.rs b/crates/io/tests/it/orientation.rs similarity index 100% rename from crates/io/tests/orientation.rs rename to crates/io/tests/it/orientation.rs diff --git a/crates/io/tests/pattern_fill_psd.rs b/crates/io/tests/it/pattern_fill_psd.rs similarity index 100% rename from crates/io/tests/pattern_fill_psd.rs rename to crates/io/tests/it/pattern_fill_psd.rs diff --git a/crates/io/tests/pcraft_manifest_name.rs b/crates/io/tests/it/pcraft_manifest_name.rs similarity index 98% rename from crates/io/tests/pcraft_manifest_name.rs rename to crates/io/tests/it/pcraft_manifest_name.rs index 285fa02203..fffb88fa8b 100644 --- a/crates/io/tests/pcraft_manifest_name.rs +++ b/crates/io/tests/it/pcraft_manifest_name.rs @@ -1,7 +1,7 @@ //! A `.pcraft` bundle's manifest reports the file it is in (#3312): writing a document to a new //! name must not leave the previous one inside, on every path that produces `.pcraft` bytes. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/pdn_corpus.rs b/crates/io/tests/it/pdn_corpus.rs similarity index 98% rename from crates/io/tests/pdn_corpus.rs rename to crates/io/tests/it/pdn_corpus.rs index 1e67b3d22b..c85b87f3ee 100644 --- a/crates/io/tests/pdn_corpus.rs +++ b/crates/io/tests/it/pdn_corpus.rs @@ -1,5 +1,5 @@ //! Local PDN oracles are not bundled. Set PDN_TEST_DIR to a directory containing PDNs and their -//! exported PNGs, then run `cargo test -p photocraft-io --test pdn_corpus -- --ignored --nocapture`. +//! exported PNGs, then run `cargo test -p photocraft-io --test it -- pdn_corpus:: --ignored --nocapture`. use std::path::{Path, PathBuf}; diff --git a/crates/io/tests/png_text_metadata.rs b/crates/io/tests/it/png_text_metadata.rs similarity index 100% rename from crates/io/tests/png_text_metadata.rs rename to crates/io/tests/it/png_text_metadata.rs diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/it/psd16_merged_transparency.rs similarity index 97% rename from crates/io/tests/psd16_merged_transparency.rs rename to crates/io/tests/it/psd16_merged_transparency.rs index 02d6c0e6b3..03030e557c 100644 --- a/crates/io/tests/psd16_merged_transparency.rs +++ b/crates/io/tests/it/psd16_merged_transparency.rs @@ -5,7 +5,7 @@ //! Photoshop's 16-bit PSB in the report has four channels, a positive layer count in `Lr16`, no //! `Mt16` block, and one alpha channel name, "Transparency". Re-saving it wrote five channels. -mod common; +use crate::common; use common::raster; use photocraft_color::{ColorMode, SampleType}; @@ -43,7 +43,7 @@ fn resave_keeps_a_name_marked_merged_transparency() { // transparency named in the alpha channel names. let mut ps = ours.clone(); ps.layer_info.as_mut().expect("layers").merged_alpha = false; - ps.global_blocks.retain(|b| !matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")); + ps.global_blocks.retain(|b| !b.is_merged_transparency_marker()); assert!(!ps.merged_has_alpha(), "{case}: nothing marks it yet"); let name: Vec = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect(); ps.resources.retain(|r| !matches!(r.id, 1006 | 1045)); diff --git a/crates/io/tests/psd32_export.rs b/crates/io/tests/it/psd32_export.rs similarity index 99% rename from crates/io/tests/psd32_export.rs rename to crates/io/tests/it/psd32_export.rs index b4707fadb0..f9a297a7f6 100644 --- a/crates/io/tests/psd32_export.rs +++ b/crates/io/tests/it/psd32_export.rs @@ -6,7 +6,7 @@ //! resources, layer info in an `Lr32` global block with an empty classic layer info, channel //! compression codes valid for 32-bit data, and a raw big-endian float merged image. -mod common; +use crate::common; use common::Features; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/psd_background.rs b/crates/io/tests/it/psd_background.rs similarity index 100% rename from crates/io/tests/psd_background.rs rename to crates/io/tests/it/psd_background.rs diff --git a/crates/io/tests/psd_block_alignment.rs b/crates/io/tests/it/psd_block_alignment.rs similarity index 100% rename from crates/io/tests/psd_block_alignment.rs rename to crates/io/tests/it/psd_block_alignment.rs diff --git a/crates/io/tests/psd_block_layout.rs b/crates/io/tests/it/psd_block_layout.rs similarity index 99% rename from crates/io/tests/psd_block_layout.rs rename to crates/io/tests/it/psd_block_layout.rs index bb858633b5..d081bfbc43 100644 --- a/crates/io/tests/psd_block_layout.rs +++ b/crates/io/tests/it/psd_block_layout.rs @@ -3,7 +3,7 @@ //! to even, a layer info body not padded to 4 and, in PSB, `cinf`/`lnkE` read and written //! with 4-byte lengths; psd-tools rejected or misaligned all of them. -mod common; +use crate::common; use photocraft_io::*; use photocraft_psd::descriptor::{Descriptor, VersionedDescriptor}; diff --git a/crates/io/tests/psd_damaged_merged.rs b/crates/io/tests/it/psd_damaged_merged.rs similarity index 100% rename from crates/io/tests/psd_damaged_merged.rs rename to crates/io/tests/it/psd_damaged_merged.rs diff --git a/crates/io/tests/psd_empty_channels.rs b/crates/io/tests/it/psd_empty_channels.rs similarity index 99% rename from crates/io/tests/psd_empty_channels.rs rename to crates/io/tests/it/psd_empty_channels.rs index 285f03cfcd..610bb145cc 100644 --- a/crates/io/tests/psd_empty_channels.rs +++ b/crates/io/tests/it/psd_empty_channels.rs @@ -1,4 +1,4 @@ -mod common; +use crate::common; use common::Features; use photocraft_color::{ColorMode, SampleType}; use photocraft_io::{ExportOptions, export}; diff --git a/crates/io/tests/psd_font_identity.rs b/crates/io/tests/it/psd_font_identity.rs similarity index 100% rename from crates/io/tests/psd_font_identity.rs rename to crates/io/tests/it/psd_font_identity.rs diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs similarity index 99% rename from crates/io/tests/psd_kerning.rs rename to crates/io/tests/it/psd_kerning.rs index ad492fce25..8b85c4f3ba 100644 --- a/crates/io/tests/psd_kerning.rs +++ b/crates/io/tests/it/psd_kerning.rs @@ -228,7 +228,7 @@ 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 hi = layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap() as usize; + 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/psd_layer_block_length.rs b/crates/io/tests/it/psd_layer_block_length.rs similarity index 99% rename from crates/io/tests/psd_layer_block_length.rs rename to crates/io/tests/it/psd_layer_block_length.rs index acb4a6b15c..71f8e2552d 100644 --- a/crates/io/tests/psd_layer_block_length.rs +++ b/crates/io/tests/it/psd_layer_block_length.rs @@ -4,7 +4,7 @@ //! Photoshop writes these blocks this way. Readers that check the block length exactly (Unity's //! PSD Importer, "Incorrect length for InfoLayers") reject a block whose length counts the padding. -mod common; +use crate::common; use common::Features; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/psd_maximize_compatibility.rs b/crates/io/tests/it/psd_maximize_compatibility.rs similarity index 100% rename from crates/io/tests/psd_maximize_compatibility.rs rename to crates/io/tests/it/psd_maximize_compatibility.rs diff --git a/crates/io/tests/psd_noise_fill_roundtrip.rs b/crates/io/tests/it/psd_noise_fill_roundtrip.rs similarity index 100% rename from crates/io/tests/psd_noise_fill_roundtrip.rs rename to crates/io/tests/it/psd_noise_fill_roundtrip.rs diff --git a/crates/io/tests/it/psd_recovery_generated.rs b/crates/io/tests/it/psd_recovery_generated.rs new file mode 100644 index 0000000000..7b9e4bb2e7 --- /dev/null +++ b/crates/io/tests/it/psd_recovery_generated.rs @@ -0,0 +1,106 @@ +//! PSD/PSB recovery regression tests using in-memory Rust-generated fixtures. +//! +//! No `.psd` file is checked into the repository and no Python generator runs. +//! Fixtures are created at the beginning of each test, so parallel test order +//! does not matter. See the ignored test for GUI-ready temporary files. + +#[path = "../support/recovery_psd.rs"] +mod recovery_psd; + +use photocraft_io::{ImportOptions, import, import_for_open}; +use photocraft_psd::PsdFile; + +/// Verifies a generated, two-layer PSD opens without source restrictions or damage prompts. +#[test] +fn valid_generated_psd_opens_normally() { + let bytes = recovery_psd::make_psd(false); + assert_eq!(bytes.len(), 74_986, "the fixture should retain the reference PSD layout"); + let result = import("valid-two-layers.psd", &bytes).expect("generated healthy PSD must import"); + assert!(!result.source_read_only); + assert!(!result.preview_only); + assert_eq!(result.document.layers.len(), 2); + let (_, damaged) = import_for_open("valid-two-layers.psd", &bytes, &ImportOptions::default()).unwrap(); + assert!(!damaged); +} + +/// Verifies a truncated green PackBits row leaves the PSD container and merged image +/// readable, while normal import reports a warning and interactive open reports damage. +#[test] +fn corrupt_generated_rle_channel_keeps_best_effort_import_and_reports_damage() { + let bytes = recovery_psd::make_psd(true); + assert_eq!(bytes.len(), 74_986, "the fixture should retain the reference PSD layout"); + let (parsed, _) = PsdFile::from_bytes_and_merged(&bytes, |_| false).expect("the PSD container must stay valid"); + let layer = parsed.layers().iter().find(|layer| layer.name() == "BROKEN orange RLE").expect("generated upper layer exists"); + + assert!(layer.decode_channel(0, parsed.header.depth, parsed.header.version).is_ok()); + assert!(layer.decode_channel(1, parsed.header.depth, parsed.header.version).is_err()); + assert!(layer.decode_channel(2, parsed.header.depth, parsed.header.version).is_ok()); + assert!(parsed.decode_merged().is_ok(), "the composite must remain recoverable"); + + let imported = import("corrupt-green-rle.psd", &bytes).expect("headless imports keep readable content"); + assert!(!imported.source_read_only); + assert!(!imported.preview_only); + assert_eq!(imported.document.layers.len(), 2); + assert!(imported.warnings.iter().any(|w| w.contains("could not be decoded"))); + + let (opened, damaged) = import_for_open("corrupt-green-rle.psd", &bytes, &ImportOptions::default()).unwrap(); + assert!(damaged, "interactive opening must ask whether to continue"); + assert!(!opened.source_read_only, "the flag does not introduce a save restriction"); + assert_eq!(opened.warnings, imported.warnings); + assert_eq!(opened.document.layers.len(), imported.document.layers.len()); +} + +/// Verifies a truncated merged preview triggers confirmation without losing intact layers. +#[test] +fn damaged_composite_keeps_layers_and_requests_interactive_confirmation() { + let mut file = PsdFile::from_bytes(&recovery_psd::make_psd(false)).unwrap(); + file.image_data.data.truncate(file.image_data.data.len() / 2); + let bytes = file.to_bytes().unwrap(); + let imported = import("damaged-preview.psd", &bytes).expect("intact layers are still usable"); + assert_eq!(imported.document.layers.len(), 2); + assert!(!imported.source_read_only); + let (opened, damaged) = import_for_open("damaged-preview.psd", &bytes, &ImportOptions::default()).unwrap(); + assert!(damaged); + assert_eq!(opened.warnings, imported.warnings); + assert!(opened.warnings.iter().any(|w| w.contains("merged image"))); +} + +/// Verifies mismatched resolution metadata produces a warning but no damage prompt. +#[test] +fn ordinary_compatibility_warning_does_not_mean_damaged() { + use photocraft_psd::{ImageResource, ResolutionInfo, resources::ids}; + let mut file = PsdFile::from_bytes(&recovery_psd::make_psd(false)).unwrap(); + let resolution = ResolutionInfo { v_res_fixed: ResolutionInfo::from_dpi(150.0).v_res_fixed, ..ResolutionInfo::from_dpi(300.0) }; + file.resources.push(ImageResource::new(ids::RESOLUTION_INFO, resolution.to_bytes())); + let (opened, damaged) = import_for_open("resolution.psd", &file.to_bytes().unwrap(), &ImportOptions::default()).unwrap(); + assert!(!damaged); + assert!(opened.warnings.iter().any(|w| w.contains("vertical resolution"))); +} + +/// Verifies a structurally unparseable PSD still fails instead of offering recovery. +#[test] +fn fatal_parse_errors_are_not_turned_into_confirmation_requests() { + let bytes = b"8BPS\0"; + assert!(import("truncated.psd", bytes).is_err()); + assert!(import_for_open("truncated.psd", bytes, &ImportOptions::default()).is_err()); +} + +/// Generate the same two PSDs on demand for manual testing in the GUI. +/// +/// Run with: +/// cargo test -p photocraft-io --test it \ +/// psd_recovery_generated::write_psds_for_manual_gui_test -- --ignored --nocapture +/// +/// The files go to the system's temp folder, not into the repository. +#[test] +#[ignore = "writes temporary PSD fixtures only when manual GUI testing is requested"] +fn write_psds_for_manual_gui_test() { + let output = std::env::temp_dir().join(format!("photocraft-psd-recovery-{}", std::process::id())); + std::fs::create_dir_all(&output).expect("create temporary fixture directory"); + + for (name, corrupt) in [("valid-two-layers.psd", false), ("corrupt-green-rle.psd", true)] { + let path = output.join(name); + std::fs::write(&path, recovery_psd::make_psd(corrupt)).expect("write generated temporary PSD"); + println!("{}", path.display()); + } +} diff --git a/crates/io/tests/psd_roundtrip_bytes.rs b/crates/io/tests/it/psd_roundtrip_bytes.rs similarity index 100% rename from crates/io/tests/psd_roundtrip_bytes.rs rename to crates/io/tests/it/psd_roundtrip_bytes.rs diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/it/psd_structure.rs similarity index 99% rename from crates/io/tests/psd_structure.rs rename to crates/io/tests/it/psd_structure.rs index 410a9f60e9..a110fbd367 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/it/psd_structure.rs @@ -290,7 +290,7 @@ fn a_colour_book_ink_shows_its_lab_equivalent_or_the_grey_plate() { assert_eq!(duo.inks.len(), 2); let [r, g, b] = duo.inks[1].color; assert!(r < 0.1 && g > 0.4 && b > 0.35, "{:?}", duo.inks[1].color); - assert_eq!(duo.psd_raw.as_ref(), Some(&f.color_mode_data), "the block is kept raw"); + assert_eq!(duo.psd_raw.as_deref(), Some(f.color_mode_data.as_slice()), "the block is kept raw"); } #[test] @@ -633,9 +633,7 @@ fn adjustment_and_fill_layers_from_psd() { use photocraft_doc::{Adjustment, Fill}; assert!(matches!(d.layers[n - 4].content, LayerContent::Adjustment(Adjustment::Invert))); assert!(matches!(d.layers[n - 3].content, LayerContent::Adjustment(Adjustment::Posterize { levels: 4 }))); - assert!( - matches!(&d.layers[n - 2].content, LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if psd_key == "selc" && raw == &vec![1, 2, 3]) - ); + assert!(matches!(&d.layers[n - 2].content, LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if psd_key == "selc" && **raw == [1, 2, 3])); assert!(matches!(&d.layers[n - 1].content, LayerContent::Fill(Fill::Solid(c)) if c.c[0] == 1.0)); } diff --git a/crates/io/tests/psd_style_sheets.rs b/crates/io/tests/it/psd_style_sheets.rs similarity index 100% rename from crates/io/tests/psd_style_sheets.rs rename to crates/io/tests/it/psd_style_sheets.rs diff --git a/crates/io/tests/psd_thumbnail.rs b/crates/io/tests/it/psd_thumbnail.rs similarity index 100% rename from crates/io/tests/psd_thumbnail.rs rename to crates/io/tests/it/psd_thumbnail.rs diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/it/psd_type_bounds.rs similarity index 99% rename from crates/io/tests/psd_type_bounds.rs rename to crates/io/tests/it/psd_type_bounds.rs index a80bc9b7ab..b87d4f4a3a 100644 --- a/crates/io/tests/psd_type_bounds.rs +++ b/crates/io/tests/it/psd_type_bounds.rs @@ -10,7 +10,7 @@ //! //! Fonts: the bundled Inter (deterministic), so our render is the "Photoshop" render here. -mod common; +use crate::common; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::TextShape; diff --git a/crates/io/tests/psd_type_photoshop_bytes.rs b/crates/io/tests/it/psd_type_photoshop_bytes.rs similarity index 100% rename from crates/io/tests/psd_type_photoshop_bytes.rs rename to crates/io/tests/it/psd_type_photoshop_bytes.rs diff --git a/crates/io/tests/psd_unknown_adjustment.rs b/crates/io/tests/it/psd_unknown_adjustment.rs similarity index 99% rename from crates/io/tests/psd_unknown_adjustment.rs rename to crates/io/tests/it/psd_unknown_adjustment.rs index 5a7981c5c8..8b1f0cbdd3 100644 --- a/crates/io/tests/psd_unknown_adjustment.rs +++ b/crates/io/tests/it/psd_unknown_adjustment.rs @@ -48,7 +48,7 @@ fn unknown_adjustment_key_opens_as_unsupported_and_saves_verbatim() { let (d, warnings) = open(&f); let k = String::from_utf8_lossy(&key).into_owned(); assert!( - matches!(content(&d, "Mystery"), LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if *psd_key == k && *raw == data), + matches!(content(&d, "Mystery"), LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if *psd_key == k && **raw == data[..]), "{k}: {:?}", content(&d, "Mystery") ); diff --git a/crates/io/tests/psd_vertical_type.rs b/crates/io/tests/it/psd_vertical_type.rs similarity index 100% rename from crates/io/tests/psd_vertical_type.rs rename to crates/io/tests/it/psd_vertical_type.rs diff --git a/crates/io/tests/raw.rs b/crates/io/tests/it/raw.rs similarity index 82% rename from crates/io/tests/raw.rs rename to crates/io/tests/it/raw.rs index 2927634993..52250c24d7 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/it/raw.rs @@ -22,6 +22,28 @@ fn dng_opens_as_16_bit_prophoto() { assert!(r.warnings.iter().any(|w| w.contains("DNG") && w.contains("ProPhoto")), "{:?}", r.warnings); } +/// #8125: a lossy DNG's floating-point LinearRaw data, JPEG XL-compressed, is developed (not +/// replaced by a preview). The test codestream is an integer one the reader takes as floats. +#[test] +fn floating_point_jpeg_xl_dng_is_developed() { + use photocraft_raw::testgen::DngStorage; + use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions}; + let (w, h) = (24, 16); + let data: Vec = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 20000.0) as u16)).collect(); + let bytes: Vec = data.iter().flat_map(|v| v.to_ne_bytes()).collect(); + let mut segment = Vec::new(); + zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, ColorSpace::RGB, BitDepth::Sixteen)).encode(&mut segment).unwrap(); + let mut spec = DngSpec::cfa(w, h, data); + spec.samples = 3; + spec.white = 0; + spec.storage = DngStorage::JxlStrips { rows: h }; + spec.jxl_segments = vec![segment]; + spec.raw_extra = vec![(339, Val::Short(vec![3; 3]))]; + let r = import("lossy.dng", &spec.build()).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height, r.document.depth), (24, 16, SampleType::U16)); + assert!(r.warnings.iter().all(|w| !w.contains("preview")), "{:?}", r.warnings); +} + /// #3497: a monochrome (one-sample LinearRaw) DNG, as VueScan writes film scans, opens as a /// 16-bit RGB document (neutral grey) instead of failing as unsupported. #[test] @@ -194,6 +216,79 @@ fn unsupported_raw_falls_back_to_the_embedded_preview() { assert!(r.warnings.first().is_some_and(|w| w.contains("Nikon compressed NEF") && w.contains("embedded")), "{:?}", r.warnings); } +/// A DNG 1.7 laid out as converters write one with JPEG XL raw data: an 8×6 JPEG thumbnail in +/// IFD0, raw data this reader refuses (here floating-point LinearRaw), and a 40×30 preview +/// compressed with JPEG XL, stored as the raw is (IFD0 Orientation 6). +fn dng_with_jxl_preview() -> Vec { + use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions}; + let thumb = Image::from_u8(8, 6, ChannelLayout::Rgb, vec![90; 8 * 6 * 3]).unwrap(); + let thumb = photocraft_codecs::encode(&thumb, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let (w, h) = (40usize, 30usize); + let rgb: Vec = (0..w * h).flat_map(|i| [(i % w * 6) as u8, (i / w * 8) as u8, 120]).collect(); + let mut preview = Vec::new(); + zune_jpegxl::JxlSimpleEncoder::new(&rgb, EncoderOptions::new(w, h, ColorSpace::RGB, BitDepth::Eight)).encode(&mut preview).unwrap(); + let mut t = TiffBuilder::default(); + let raw_strip = t.blob(vec![0; 4 * 4 * 3 * 2]); + let raw = t.ifd(vec![ + (254, Val::Long(vec![0])), + (256, Val::Long(vec![4])), + (257, Val::Long(vec![4])), + (258, Val::Short(vec![16, 16, 16])), + (259, Val::Short(vec![1])), + (262, Val::Short(vec![34892])), + (273, Val::Blobs(vec![raw_strip])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![4])), + (279, Val::Long(vec![4 * 4 * 3 * 2])), + (339, Val::Short(vec![3, 3, 3])), + ]); + let preview_len = preview.len() as u32; + let preview_strip = t.blob(preview); + let preview = t.ifd(vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![w as u32])), + (257, Val::Long(vec![h as u32])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![52546])), + (262, Val::Short(vec![2])), + (273, Val::Blobs(vec![preview_strip])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![h as u32])), + (279, Val::Long(vec![preview_len])), + (50970, Val::Long(vec![2])), + ]); + let thumb_len = thumb.len() as u32; + let thumb_strip = t.blob(thumb); + let ifd0 = t.ifd(vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![8])), + (257, Val::Long(vec![6])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![7])), + (262, Val::Short(vec![6])), + (271, Val::Ascii("Synthetic".into())), + (273, Val::Blobs(vec![thumb_strip])), + (274, Val::Short(vec![6])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![6])), + (279, Val::Long(vec![thumb_len])), + (330, Val::Ifds(vec![raw, preview])), + (50706, Val::Byte(vec![1, 7, 0, 0])), + ]); + t.chain = vec![ifd0]; + t.build() +} + +/// #8125: a DNG whose raw data isn't supported opens its full JPEG XL preview, upright, not +/// the small JPEG thumbnail. +#[test] +fn unsupported_dng_falls_back_to_its_jpeg_xl_preview() { + let r = import("photo.dng", &dng_with_jxl_preview()).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (30, 40), "the 40×30 preview, turned upright: {:?}", r.warnings); + let w = r.warnings.first().map_or("", String::as_str); + assert!(w.contains("floating-point") && w.contains("40x30 JPEG XL preview"), "{:?}", r.warnings); +} + #[test] fn unsupported_raw_without_preview_is_a_clear_error() { let mut cr3 = vec![0, 0, 0, 24]; diff --git a/crates/io/tests/resolution_axes.rs b/crates/io/tests/it/resolution_axes.rs similarity index 99% rename from crates/io/tests/resolution_axes.rs rename to crates/io/tests/it/resolution_axes.rs index f7f8cbf006..fb2bd209e2 100644 --- a/crates/io/tests/resolution_axes.rs +++ b/crates/io/tests/it/resolution_axes.rs @@ -1,7 +1,7 @@ //! A file whose horizontal and vertical resolutions differ opens at the horizontal one, and the //! import says the vertical one is not kept (#1018). Equal axes, the common case, open silently. -mod common; +use crate::common; use common::*; use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, Metadata, SampleType as CSample}; diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/it/roundtrip.rs similarity index 99% rename from crates/io/tests/roundtrip.rs rename to crates/io/tests/it/roundtrip.rs index dcb7bce62a..155429eba2 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/it/roundtrip.rs @@ -1,6 +1,6 @@ //! Document → PSD → Document round trips. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; @@ -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/smart_contents_identity.rs b/crates/io/tests/it/smart_contents_identity.rs similarity index 100% rename from crates/io/tests/smart_contents_identity.rs rename to crates/io/tests/it/smart_contents_identity.rs diff --git a/crates/io/tests/smart_corpus.rs b/crates/io/tests/it/smart_corpus.rs similarity index 99% rename from crates/io/tests/smart_corpus.rs rename to crates/io/tests/it/smart_corpus.rs index 494a43071c..f258c01103 100644 --- a/crates/io/tests/smart_corpus.rs +++ b/crates/io/tests/it/smart_corpus.rs @@ -10,7 +10,7 @@ //! ones dropped). #![cfg(feature = "corpus")] -mod common; +use crate::common; use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/crates/io/tests/svg.rs b/crates/io/tests/it/svg.rs similarity index 81% rename from crates/io/tests/svg.rs rename to crates/io/tests/it/svg.rs index 4f21bd5dcb..c71bb0ae4f 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -190,6 +190,31 @@ fn png_data_url() -> String { out } +/// A small valid PNG whose IHDR claims `w`x`h`, far larger than its actual (tiny) image data. +/// The header alone must be enough to reject it before any decode is attempted. +fn png_data_url_with_claimed_size(w: u32, h: u32) -> String { + let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]).unwrap(); + let mut bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + // IHDR starts right after the 8-byte signature and 8-byte chunk length/type: width at 16, + // height at 20 (big-endian u32 each). + bytes[16..20].copy_from_slice(&w.to_be_bytes()); + bytes[20..24].copy_from_slice(&h.to_be_bytes()); + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::from("data:image/png;base64,"); + for chunk in bytes.chunks(3) { + let n = chunk.len(); + let v = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0)); + for i in 0..4 { + if i <= n { + out.push(T[((v >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + #[test] fn embedded_raster_images_become_pixel_layers() { let r = open(&format!( @@ -204,6 +229,18 @@ fn embedded_raster_images_become_pixel_layers() { assert!(close(px(d, 16, 16), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(d, 16, 16)); } +#[test] +fn embedded_png_with_a_huge_claimed_size_is_not_decoded() { + // The IHDR claims 40000x40000 (6.4 GB RGBA decoded) but the display size is tiny; the + // header-only check must reject it before resvg ever decodes the actual (2x2) image data. + let r = open(&format!( + "", + png_data_url_with_claimed_size(40_000, 40_000) + )); + // Rejected at the resolver: no image layer is produced (resvg treats it as a missing image). + assert!(r.document.layers.is_empty() || !matches!(r.document.layers[0].content, LayerContent::Raster(_)), "{:?}", r.document.layers); +} + #[test] fn text_becomes_outline_shapes_or_warns_without_a_font() { let r = open(&format!( @@ -221,57 +258,6 @@ fn text_becomes_outline_shapes_or_warns_without_a_font() { assert!(dark > 4, "glyph strokes cross the middle row: {dark}"); } -/// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text -/// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the -/// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the -/// text went off the canvas or kept its untransformed place and size. -#[test] -fn transformed_text_lands_where_resvg_draws_it() { - let font = "font-size=\"24\" font-family=\"DejaVu Sans, Arial, Helvetica, sans-serif\" fill=\"black\""; - let cases = [ - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - ]; - for c in cases { - let svg = format!("{c}"); - let r = open(&svg); - let d = &r.document; - if d.layers.is_empty() { - // No font at all on this machine: the import says so instead. - assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings); - return; - } - assert!(matches!(d.layers[0].content, LayerContent::Group(_)), "text stays outline shapes: {c}"); - let imported = photocraft_compose::flatten(d); - let reference = svg::rasterize(svg.as_bytes(), 1.0).unwrap(); - let (mut both, mut either) = (0usize, 0usize); - let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]); - let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)]; - for y in 0..60 { - for x in 0..120 { - let (a, b) = (imported.get(x, y)[3] > 0.5, reference.get(x, y)[3] > 0.5); - if a { - grow(&mut a_box, x, y); - } - if b { - grow(&mut b_box, x, y); - } - both += usize::from(a && b); - either += usize::from(a || b); - } - } - assert!(either > 50, "resvg draws the text on the canvas: {c}"); - // Same place and size to the pixel; the outlines are filled by a different rasteriser, so - // the edges of thin stems may differ by half a pixel. - assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}"); - assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}"); - } -} - #[test] fn size_comes_from_the_viewbox_and_huge_drawings_shrink_to_fit() { let r = open(&format!("")); @@ -296,6 +282,52 @@ fn gzip(bytes: &[u8]) -> Vec { e.finish().unwrap() } +/// A `data:` URL of `bytes` under `mime`, base64-encoded. +fn base64_data_url(mime: &str, bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = format!("data:{mime};base64,"); + for chunk in bytes.chunks(3) { + let n = chunk.len(); + let v = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0)); + for i in 0..4 { + if i <= n { + out.push(T[((v >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +#[test] +fn gzip_compressed_embedded_svg_decompresses_once_with_a_size_cap() { + // A small gzip-compressed SVG embedded via data URL (SVGZ-in-data-URL) must still import. + let inner = format!(""); + let url = base64_data_url("image/svg+xml", &gzip(inner.as_bytes())); + let r = open(&format!("")); + assert!( + r.document.layers.iter().any(|l| matches!(l.content, LayerContent::Raster(_))), + "valid embedded gzip SVG must decode into a raster layer: {:?}", + r.document.layers + ); + + // A gzip-compressed embedded SVG whose decompressed size exceeds the cap must be rejected + // by our bounded inflate, not silently decompressed again without a limit by usvg. + let mut bomb = format!("").into_bytes(); + bomb.resize(usize::try_from(svg::MAX_INFLATED_BYTES).unwrap() + 1024, b' '); + bomb.extend_from_slice(b""); + let gz = gzip(&bomb); + assert!(gz.len() < 2 << 20, "{} bytes compressed", gz.len()); + let url = base64_data_url("image/svg+xml", &gz); + let start = std::time::Instant::now(); + let r = open(&format!("")); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "took {:?}", start.elapsed()); + // The embedded bomb is rejected; the outer drawing may still open (e.g. with a missing + // image), but must not have decoded the bomb into a raster layer. + assert!(r.document.layers.iter().all(|l| !matches!(l.content, LayerContent::Raster(_))), "{:?}", r.document.layers); +} + /// 8 000 nested groups (70 KB) overflowed the XML parser's recursion on a 2 MB thread stack, an /// abort nothing can catch. It is an error now, found before the parser runs; the thread here /// has a small stack so a regression aborts the test instead of passing by luck. diff --git a/crates/io/tests/text_corpus.rs b/crates/io/tests/it/text_corpus.rs similarity index 63% rename from crates/io/tests/text_corpus.rs rename to crates/io/tests/it/text_corpus.rs index 8597ac49d8..32e914d051 100644 --- a/crates/io/tests/text_corpus.rs +++ b/crates/io/tests/it/text_corpus.rs @@ -169,6 +169,107 @@ fn corpus_photoshop_txt2_survives_an_unedited_save() { println!("Txt2 kept byte-for-byte in {checked} Photoshop type files"); } +/// #7962: Photoshop's type on a path (`Txt2` frames, EngineData calls it a text box) opens on its +/// path, at the anchors the generator gave Photoshop; the circle re-renders where Photoshop drew +/// it; and a save after an edit (new text, a moved path) reopens on the layer's path. +#[cfg(feature = "corpus")] +#[test] +fn corpus_photoshop_path_text_opens_on_its_path() { + use photocraft_doc::text::TextShape; + use photocraft_geom::Point; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/text"); + let k = 0.5522847498 * 96.0; + let cases: [(&str, bool, Vec<[f64; 2]>); 2] = [ + // Photoshop sets the type from the closed path's last anchor (the bottom). + ("path-circle.psd", true, vec![[128.0, 224.0], [32.0, 128.0], [128.0, 32.0], [224.0, 128.0]]), + ("path-wave-open.psd", false, vec![[16.0, 100.0], [160.0, 80.0], [304.0, 60.0]]), + ]; + let mut engine = photocraft_text::TextEngine::with_system_fonts(); + for (file, closed, anchors) in cases { + let f = root.join(file); + let bytes = std::fs::read(&f).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --photoshop`", f.display())); + let imp = photocraft_io::import(file, &bytes).unwrap(); + assert!(!imp.warnings.iter().any(|w| w.contains("type path")), "{file}: {:?}", imp.warnings); + let doc = imp.document; + let mut all = Vec::new(); + walk(&doc.layers, &mut all); + let layer = all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap(); + let LayerContent::Text(t) = &layer.content else { unreachable!() }; + let doc_anchors = |t: &photocraft_doc::TextLayer| -> Vec { + let TextShape::Path { path, start, flip } = &t.shape else { panic!("{file}: not path text: {:?}", t.shape) }; + assert_eq!((path.closed, *start, *flip), (closed, 0.0, false), "{file}"); + path.knots.iter().map(|k| t.transform.apply(k.anchor)).collect() + }; + let got = doc_anchors(t); + assert_eq!(got.len(), anchors.len(), "{file}"); + for (g, [x, y]) in got.iter().zip(&anchors) { + assert!((g.x - x).abs() < 1e-3 && (g.y - y).abs() < 1e-3, "{file}: anchor {g:?}, want ({x}, {y})"); + } + if closed { + // The circle's handles are the generator's (kappa × r), so the path is the circle. + let TextShape::Path { path, .. } = &t.shape else { unreachable!() }; + let h = t.transform.apply(path.knots[0].out_ctrl); + assert!((h.x - (128.0 - k)).abs() < 1e-3 && (h.y - 224.0).abs() < 1e-3, "{file}: handle {h:?}"); + } + + // Photoshop's pixels (the wave's EngineData text is blank: Photoshop drew nothing). + if let Some(ps) = t.cache.as_ref().filter(|c| !c.content_bounds().is_empty()) { + let ps_rect = ps.content_bounds(); + let (_, ours) = engine.render(t, doc.resolution_dpi, doc.pixel_format()); + let our_rect = ours.surface.content_bounds(); + let u = Rect::new(ps_rect.x0.min(our_rect.x0), ps_rect.y0.min(our_rect.y0), ps_rect.x1.max(our_rect.x1), ps_rect.y1.max(our_rect.y1)); + let (a, b) = (alpha(ps, u), alpha(&ours.surface, u)); + let inter: f32 = a.iter().zip(&b).map(|(x, y)| x.min(*y)).sum(); + let union: f32 = a.iter().zip(&b).map(|(x, y)| x.max(*y)).sum(); + let iou = if union > 0.0 { inter / union } else { 1.0 }; + let edges = [ps_rect.x0 - our_rect.x0, ps_rect.y0 - our_rect.y0, ps_rect.x1 - our_rect.x1, ps_rect.y1 - our_rect.y1]; + println!("{file}: ps={ps_rect:?} ours={our_rect:?} iou={iou:.2} edges={edges:?}"); + // Where the type starts on the path and how far it reaches across it hold with any + // font; the far end and the glyph overlap need the font Photoshop used (a wider + // fallback runs further around the circle). + let [x0, y0, _, y1] = edges; + assert!([x0, y0, y1].iter().all(|d| d.abs() <= 8), "{file}: ink bounds {our_rect:?}, Photoshop's {ps_rect:?}"); + let family = t.runs.first().map(|r| r.style.font_family.clone()).unwrap_or_default(); + if engine.fonts.has_family(&family) { + assert!(edges.iter().all(|d| d.abs() <= 8), "{file}: ink bounds {our_rect:?}, Photoshop's {ps_rect:?}"); + assert!(iou > 0.2, "{file}: the glyphs sit where Photoshop drew them (IoU {iou:.2})"); + } else { + eprintln!("{file}: {family:?} not installed, far edge and overlap comparison skipped"); + } + } + + // Edited: new text and the path moved 10 px right; saved and opened again. + let mut edited = doc.clone(); + let id = layer.id; + for l in edited.layers.iter_mut() { + if l.id != id { + continue; + } + let LayerContent::Text(t) = &mut l.content else { continue }; + t.text = format!("{} again", t.text); + if let Some(r) = t.runs.last_mut() { + r.len += " again".len(); + } + t.sync_summary(); + if let TextShape::Path { path, .. } = &mut t.shape { + for k in &mut path.knots { + *k = k.transform(&photocraft_geom::Affine::translate(10.0, 0.0)); + } + } + t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::build_tysh(t, edited.resolution_dpi, None))); + } + let out = photocraft_io::export(&edited, file, &Default::default()).unwrap().bytes; + let back = photocraft_io::import(file, &out).unwrap().document; + let mut all = Vec::new(); + walk(&back.layers, &mut all); + let LayerContent::Text(bt) = &all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap().content else { unreachable!() }; + assert!(bt.text.ends_with(" again"), "{file}: {:?}", bt.text); + for (g, [x, y]) in doc_anchors(bt).iter().zip(&anchors) { + assert!((g.x - (x + 10.0)).abs() < 1e-3 && (g.y - y).abs() < 1e-3, "{file}: saved anchor {g:?}, want ({}, {y})", x + 10.0); + } + } +} + /// A text layer created in PhotoCraft (no PSD data) exports as an editable type layer and /// imports back with the same model. #[test] diff --git a/crates/io/tests/text_styles.rs b/crates/io/tests/it/text_styles.rs similarity index 100% rename from crates/io/tests/text_styles.rs rename to crates/io/tests/it/text_styles.rs diff --git a/crates/io/tests/tiff_corpus.rs b/crates/io/tests/it/tiff_corpus.rs similarity index 100% rename from crates/io/tests/tiff_corpus.rs rename to crates/io/tests/it/tiff_corpus.rs diff --git a/crates/io/tests/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs new file mode 100644 index 0000000000..11e37591bc --- /dev/null +++ b/crates/io/tests/it/tiff_layers.rs @@ -0,0 +1,1123 @@ +//! Layered TIFF (Photoshop layer data in tags 37724 / 34377): Document → TIFF → Document round +//! trips in every mode and depth, equivalence with the PSD path, the flat fall-backs, files +//! written by an independent implementation (psdtags, both byte orders), and malformed input. + +use crate::common; + +use common::*; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, LayerContent}; +use photocraft_io::*; +use photocraft_psd::tiff::{ByteOrder, ImageSourceData}; + +fn tiff(doc: &Document, layers: bool) -> ExportResult { + export(doc, "x.tif", &ExportOptions { tiff_layers: layers, ..Default::default() }).expect("export") +} + +/// The two Photoshop tags of a TIFF: (resources, layers). +fn tags(bytes: &[u8]) -> (Option>, Option>) { + let (r, l) = photocraft_codecs::tiff_photoshop_tags(bytes); + (r.map(<[u8]>::to_vec), l.map(<[u8]>::to_vec)) +} + +fn roundtrip(doc: &Document) -> Document { + let r = tiff(doc, true); + let (resources, layers) = tags(&r.bytes); + let layers = layers.expect("layer data"); + assert!(resources.is_some_and(|r| !r.is_empty()), "image resources"); + // The layer data matches the container's byte order and is byte-stable through the psd crate. + let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; + let (isd, w) = ImageSourceData::from_bytes(&layers).expect("parse layer data"); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(isd.byte_order, order); + assert_eq!(isd.to_bytes(order).unwrap().0, layers); + // The TIFF is a plain image to any other reader: the composite at the document's size. + let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode"); + assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height)); + // Extra alpha channels and a Quick Mask travel as TIFF extra samples (at most MAX_EXTRA_SAMPLES). + let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some()); + assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{:?}", r.warnings); + if extra > 0 && extra <= photocraft_codecs::MAX_EXTRA_SAMPLES { + assert_eq!(flat.meta.extra_samples.len(), extra, "{:?}", r.warnings); + } + import("x.tif", &r.bytes).expect("import").document +} + +/// `doc` as a layered TIFF round trip returns it, including extra channels. +fn expected(doc: &Document) -> Document { + doc.clone() +} + +macro_rules! rt_case { + ($name:ident, $mode:expr, $depth:expr, $f:expr) => { + #[test] + fn $name() { + let d = gen_doc($mode, $depth, $f); + let back = roundtrip(&d); + assert_docs_eq(&expected(&d), &back); + } + }; +} + +rt_case!(rt_rgb8_all, ColorMode::Rgb, SampleType::U8, Features::ALL); +rt_case!(rt_rgb16_all, ColorMode::Rgb, SampleType::U16, Features::ALL); +rt_case!(rt_rgb32_all, ColorMode::Rgb, SampleType::F32, Features::ALL); +rt_case!(rt_gray8_all, ColorMode::Grayscale, SampleType::U8, Features::ALL); +rt_case!(rt_gray16_all, ColorMode::Grayscale, SampleType::U16, Features::ALL); +rt_case!(rt_gray32_all, ColorMode::Grayscale, SampleType::F32, Features::ALL); +rt_case!(rt_cmyk8_all, ColorMode::Cmyk, SampleType::U8, Features::ALL); +rt_case!(rt_cmyk16_all, ColorMode::Cmyk, SampleType::U16, Features::ALL); +rt_case!(rt_rgb8_pixels, ColorMode::Rgb, SampleType::U8, Features::PIXELS); +rt_case!(rt_gray16_pixels, ColorMode::Grayscale, SampleType::U16, Features::PIXELS); +rt_case!(rt_cmyk8_pixels, ColorMode::Cmyk, SampleType::U8, Features::PIXELS); + +#[test] +fn tiff_and_psd_open_as_the_same_document() { + for (mode, depth) in [(ColorMode::Rgb, SampleType::U8), (ColorMode::Cmyk, SampleType::U16), (ColorMode::Grayscale, SampleType::F32)] { + let d = gen_doc(mode, depth, Features::ALL); + let psd = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + let from_psd = import("x.psd", &psd.bytes).unwrap().document; + let from_tif = roundtrip(&d); + assert_docs_eq(&expected(&from_psd), &from_tif); + // The composite the TIFF carries is what the document looks like. + let tif = tiff(&d, true); + let flat = import( + "flat.tif", + &photocraft_codecs::encode(&photocraft_codecs::decode(&tif.bytes).unwrap(), photocraft_codecs::Format::Tiff, &Default::default()).unwrap(), + ) + .unwrap() + .document; + let a = photocraft_compose::flatten(&d).px; + let b = photocraft_compose::flatten(&flat).px; + assert!(max_diff(&a, &b) <= 2.0 / 255.0, "{mode:?} {depth:?}: composite differs by {}", max_diff(&a, &b)); + } +} + +#[test] +fn every_compression_keeps_the_layers() { + use photocraft_codecs::TiffCompression; + let d = gen_doc(ColorMode::Rgb, SampleType::U16, Features::PIXELS); + for c in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let mut opts = ExportOptions { tiff_layers: true, ..Default::default() }; + opts.encode.tiff_compression = c; + let r = export(&d, "x.tiff", &opts).unwrap(); + let back = import("x.tiff", &r.bytes).unwrap().document; + assert_docs_eq(&d, &back); + } +} + +/// The Artist tag's text, and the bytes every EXIF assertion looks for. +const ARTIST: &[u8] = b"fixture-artist"; + +/// A minimal little-endian EXIF (TIFF) block with a single Artist tag and no Orientation, so +/// `upright_exif` passes it through untouched. +fn exif() -> Vec { + let mut artist = ARTIST.to_vec(); + artist.push(0); // ASCII NUL terminator + let mut v = b"II*\0\x08\0\0\0".to_vec(); // TIFF header, first IFD at 8 + v.extend_from_slice(&1u16.to_le_bytes()); // one entry + v.extend_from_slice(&0x013bu16.to_le_bytes()); // Artist + v.extend_from_slice(&2u16.to_le_bytes()); // ASCII + v.extend_from_slice(&(artist.len() as u32).to_le_bytes()); + v.extend_from_slice(&26u32.to_le_bytes()); // value offset: 8 + 2 + 12 + 4 + v.extend_from_slice(&0u32.to_le_bytes()); // no next IFD + v.extend_from_slice(&artist); + v +} + +/// #1545: a layered TIFF carries EXIF as Photoshop resource 1058 and reads it back, so it must +/// not report a loss it never makes. A flat TIFF writes it as its own tags and keeps it too. +#[test] +fn layered_tiff_keeps_exif_without_claiming_a_loss() { + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + d.metadata.exif = Some(std::sync::Arc::new(exif())); + + let r = tiff(&d, true); + assert!(!r.warnings.iter().any(|w| w.contains("EXIF")), "a layered TIFF keeps EXIF, so it must not claim to drop it: {:?}", r.warnings); + // ...and the tags really are in the file, so the missing warning is a true absence of loss. + let back = import("x.tif", &r.bytes).unwrap().document; + let kept = back.metadata.exif.as_deref().expect("EXIF survives the layered export"); + assert!(kept.windows(ARTIST.len()).any(|w| w == ARTIST), "the Artist tag did not survive: {kept:?}"); + + // The flat path writes the EXIF block's tags as the TIFF's own (here IFD0's Artist, which + // reads back as the document's Artist text), so it reports no loss either. + let flat = tiff(&d, false); + assert!(!flat.warnings.iter().any(|w| w.contains("EXIF")), "a flat TIFF keeps EXIF: {:?}", flat.warnings); + let back = import("x.tif", &flat.bytes).unwrap().document; + let artist = std::str::from_utf8(ARTIST).unwrap().trim_end_matches('\0'); + assert!(back.metadata.text.iter().any(|(k, v)| k == "Artist" && v == artist), "{:?}", back.metadata.text); +} + +#[test] +fn discard_layers_writes_a_flat_file() { + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let r = tiff(&d, false); + assert_eq!(tags(&r.bytes), (None, None)); + assert!(r.warnings.iter().any(|w| w.contains("flattened")), "{:?}", r.warnings); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_eq!(back.layers.len(), 1); + assert!(!tiff_layers::would_write_layers(&back)); +} + +#[test] +fn a_plain_background_needs_no_layer_data() { + let d = import("x.png", &export(&gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS), "x.png", &Default::default()).unwrap().bytes).unwrap().document; + assert_eq!(d.layers.len(), 1); + assert!(!tiff_layers::would_write_layers(&d)); + let r = tiff(&d, true); + assert_eq!(tags(&r.bytes), (None, None)); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_docs_eq(&d, &back); +} + +#[test] +fn lab_documents_are_saved_flat_with_a_warning() { + let d = gen_doc(ColorMode::Lab, SampleType::U8, Features::PIXELS); + let r = tiff(&d, true); + assert_eq!(tags(&r.bytes), (None, None)); + assert!(r.warnings.iter().any(|w| w.contains("Lab") && w.contains("layers are not kept")), "{:?}", r.warnings); + assert!(import("x.tif", &r.bytes).is_ok()); +} + +#[test] +fn transparency_is_straight_in_the_composite() { + // A translucent layer over nothing: the TIFF's composite keeps its colour under the alpha + // instead of being matted against white like a PSD's merged image. + let mut d = Document::new("t", photocraft_geom::Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + let mut l = raster("Red", fmt, photocraft_geom::Rect::new(0, 0, 4, 4), 0, false); + if let LayerContent::Raster(s) = &mut l.content { + let px: Vec = (0..16).flat_map(|_| [1.0, 0.0, 0.0, 0.5]).collect(); + s.write_region(photocraft_geom::Rect::new(0, 0, 4, 4), &px); + } + d.layers.push(l); + let r = tiff(&d, true); + let flat = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(flat.layout().has_alpha()); + let px = flat.data(); + assert_eq!(&px[..4], &[255, 0, 0, 128]); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_docs_eq(&d, &back); +} + +/// Photoshop (27.11) saves a layered TIFF with Save Transparency as a composite with a +/// transparency sample, a positive layer count and an `MTrn` block first in the layer data +/// (the specification's key is `Mtrn`; `nrTM` in an `II` file). That sample is the merged +/// transparency: it opens as no alpha channel, and a PSD save doesn't write it twice. +#[test] +fn photoshop_mtrn_marks_the_merged_transparency_of_a_layered_tiff() { + use photocraft_psd::{PsdFile, TaggedBlock}; + for depth in [SampleType::U8, SampleType::U16] { + let mut d = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + d.layers.push(raster("shape", fmt, photocraft_geom::Rect::new(1, 1, 5, 4), 3, true)); + let r = tiff(&d, true); + let mut img = photocraft_codecs::decode(&r.bytes).expect("decode"); + assert!(img.layout().has_alpha(), "{depth:?}: the composite has a transparency sample"); + let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.expect("layer data")).expect("parse"); + for order in [ByteOrder::Little, ByteOrder::Big] { + let case = format!("{depth:?} {order:?}"); + // Photoshop's form of the same layer data. + let mut ps = isd.clone(); + ps.layer_info.as_mut().expect("layers").merged_alpha = false; + ps.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new())); + let layers = ps.to_bytes(order).expect("layer data").0; + if order == ByteOrder::Little { + assert!(layers.windows(12).any(|w| w == b"MIB8nrTM\0\0\0\0"), "{case}: stored as Photoshop stores it"); + } + img.meta.photoshop_layers = Some(layers); + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).expect("encode"); + + let opened = import("x.tif", &bytes).expect("import").document; + let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert!(names.is_empty(), "{case}: no alpha channel from the merged transparency: {names:?}"); + // The marker is kept for the next save. + let mut want = d.clone(); + want.metadata.psd_global_blocks = vec![(*b"8BIM", *b"MTrn", std::sync::Arc::new(Vec::new()))]; + assert_docs_eq(&want, &opened); + let psd = PsdFile::from_bytes(&export(&opened, "x.psd", &ExportOptions::default()).expect("PSD save").bytes).expect("parse"); + assert_eq!(psd.header.channels, 4, "{case}: colour + merged transparency"); + } + } +} + +#[test] +fn big_documents_use_the_psb_signature() { + // Forcing PSB stands in for a > 30000 px document. + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let r = export(&d, "x.tif", &ExportOptions { force_psb: true, tiff_layers: true, ..Default::default() }).unwrap(); + let (_, layers) = tags(&r.bytes); + assert!(layers.unwrap().starts_with(photocraft_psd::tiff::SIGNATURE_PSB)); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_docs_eq(&d, &back); +} + +/// Files written by psdtags (`scripts/layered_tiff_fixtures.py`): two RGB layers, "Background" +/// (RLE) and an offset, translucent "Upper é" (ZIP, Multiply, opacity 200), in both byte orders +/// at 8 and 16 bits. Binary files stay out of this repository: run the script to write them +/// to `corpus/layered-tiff/` (gitignored); without them these checks are skipped. +fn fixture(name: &str) -> Option> { + let bytes = std::fs::read(format!("{}/../../corpus/layered-tiff/{name}", env!("CARGO_MANIFEST_DIR"))).ok(); + if bytes.is_none() { + eprintln!("skipped {name}: run `python scripts/layered_tiff_fixtures.py` to write corpus/layered-tiff/"); + } + bytes +} + +#[test] +fn psdtags_fixtures_open_with_their_layers() { + for (name, depth) in [ + ("layered-le-8bit.tif", SampleType::U8), + ("layered-be-8bit.tif", SampleType::U8), + ("layered-le-16bit.tif", SampleType::U16), + ("layered-be-16bit.tif", SampleType::U16), + ] { + let Some(bytes) = fixture(name) else { continue }; + let r = import(name, &bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let d = &r.document; + assert_eq!(d.size, photocraft_geom::Size::new(6, 4), "{name}"); + assert_eq!(d.mode, ColorMode::Rgb); + assert_eq!(d.depth, depth, "{name}"); + let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Background", "Upper \u{e9}"], "{name}"); + let upper = &d.layers[1]; + assert_eq!(upper.blend, photocraft_color::BlendMode::Multiply); + assert!((upper.opacity - 200.0 / 255.0).abs() < 1e-6); + let LayerContent::Raster(s) = &upper.content else { panic!("raster") }; + let bounds = s.content_bounds(); + assert_eq!((bounds.x0, bounds.y0, bounds.width(), bounds.height()), (1, 1, 4, 3), "{name}"); + // Pixel (x=2, y=0) of the upper layer: the generator's ramp, alpha 160/255. + let px = s.pixel(3, 1); + let expect = |v: u32| (v % 256) as f32 / 255.0; + assert!((px[0] - expect(2 * 40 + 7)).abs() < 1e-3, "{name}: {px:?}"); + assert!((px[1] - expect(7 * 3)).abs() < 1e-3, "{name}: {px:?}"); + assert!((px[3] - 160.0 / 255.0).abs() < 1e-3, "{name}: {px:?}"); + let LayerContent::Raster(bg) = &d.layers[0].content else { panic!("raster") }; + let b = bg.pixel(5, 3); + assert!((b[0] - expect(5 * 40 + 1)).abs() < 1e-3, "{name}: {b:?}"); + assert!((b[3] - 1.0).abs() < 1e-6); + assert_eq!(d.resolution_dpi, 300.0); + // tifffile writes a Software tag; nothing else is worth a warning. + assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings); + // psdtags wrote the composite's alpha sample without the transparency flag, so by the + // PSD rules it is an alpha channel. A re-save keeps it as a TIFF extra sample. + assert_eq!(d.channels.len(), 1, "{name}"); + let out = tiff(d, true); + let back = import(name, &out.bytes).unwrap().document; + assert_docs_eq(d, &back); + } +} + +/// Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`), fill opacity (`iOpa`), +/// channel restrictions (`brst`) and Blend If (the records' blending ranges) survive a layered +/// TIFF with its layer data in either byte order: the encoder's own, and the other one made by +/// transcoding the tag (the same length, spliced in place). +#[test] +fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { + use photocraft_doc::{AdvancedBlending, BlendRange, Knockout}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut d = gen_doc(ColorMode::Rgb, depth, Features::PIXELS); + let adv = AdvancedBlending { + knockout: Knockout::Deep, + blend_interior: true, + blend_clipped: false, + transparency_shapes: false, + layer_mask_hides_effects: true, + vector_mask_hides_effects: true, + }; + d.layers[1].advanced = adv; + d.layers[1].fill_opacity = 64.0 / 255.0; + d.layers[1].excluded_channels = 0b010; + d.layers[1].blend_if.set(0, [BlendRange { black: [10, 40], white: [200, 250] }, BlendRange { black: [0, 0], white: [128, 128] }]); + d.layers[2].advanced.knockout = Knockout::Shallow; + let r = tiff(&d, true); + let (_, layers) = tags(&r.bytes); + let layers = layers.expect("layer data"); + let (isd, w) = ImageSourceData::from_bytes(&layers).expect("parse"); + assert!(w.is_empty(), "{w:?}"); + let native = isd.byte_order; + let other = if native == ByteOrder::Little { ByteOrder::Big } else { ByteOrder::Little }; + let (swapped, w) = isd.to_bytes(other).expect("transcode"); + assert!(w.is_empty(), "{depth:?}: blocks dropped while transcoding: {w:?}"); + assert_eq!(swapped.len(), layers.len()); + let at = r.bytes.windows(layers.len()).position(|x| x == &layers[..]).expect("tag data in the file"); + let mut flipped = r.bytes.clone(); + flipped[at..at + layers.len()].copy_from_slice(&swapped); + for (order, bytes) in [(native, &r.bytes), (other, &flipped)] { + let back = import("x.tif", bytes).unwrap_or_else(|e| panic!("{depth:?} {order:?}: {e}")).document; + let l = &back.layers[1]; + assert_eq!(l.advanced, adv, "{depth:?} {order:?}"); + assert_eq!(l.fill_opacity, 64.0 / 255.0, "{depth:?} {order:?}"); + assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}"); + assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}"); + assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}"); + assert_docs_eq(&expected(&d), &back); + } + } +} + +#[test] +fn damaged_layer_data_opens_flattened() { + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let r = tiff(&d, true); + let (_, layers) = tags(&r.bytes); + let layers = layers.unwrap(); + let at = r.bytes.windows(layers.len()).position(|w| w == &layers[..]).unwrap(); + // Garbage after the signature: no layers, a warning, the composite as one layer. + let mut bad = r.bytes.clone(); + for b in &mut bad[at + 36..at + 36 + 24] { + *b ^= 0xa5; + } + let r2 = import("x.tif", &bad).unwrap(); + assert_eq!(r2.document.layers.len(), 1); + assert!(r2.warnings.iter().any(|w| w.contains("could not be read")), "{:?}", r2.warnings); + // Truncations and bit flips through the whole file: never a panic. A run takes a quarter of + // them, a different quarter each day. + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; + let mut runs = || { + case += 1; + sweep.includes(case) + }; + for cut in (0..r.bytes.len()).step_by(11).filter(|_| runs()) { + let _ = import("x.tif", &r.bytes[..cut]); + } + for i in (0..r.bytes.len()).step_by(29).filter(|_| runs()) { + let mut b = r.bytes.clone(); + b[i] ^= 0x5a; + let _ = import("x.tif", &b); + } + for name in ["layered-le-8bit.tif", "layered-be-16bit.tif"] { + let Some(bytes) = fixture(name) else { continue }; + for cut in (0..bytes.len()).step_by(7).filter(|_| runs()) { + let _ = import(name, &bytes[..cut]); + } + for i in (0..bytes.len()).step_by(5).filter(|_| runs()) { + let mut b = bytes.clone(); + b[i] ^= 0xff; + let _ = import(name, &b); + } + } +} + +/// Every sample of a TIFF, read by the `tiff` crate rather than PhotoCraft's decoder. +/// +/// That decoder drops unspecified extra samples when the photometric interpretation is RGB, so +/// the file is retagged MinIsBlack first. The result is then a multiband image of every sample, +/// which is what libtiff's `TIFFReadScanline` returns and what a RIP walks. +fn independent_samples(bytes: &[u8]) -> (u16, Vec, Vec, u32, Vec) { + let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).expect("tiff tags"); + let spp: u16 = tags.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).expect("spp"); + let bps = tags.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).expect("bps"); + let extra = tags.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap_or_default(); + let rows = tags.find_tag_unsigned::(tiff::tags::Tag::RowsPerStrip).ok().flatten().unwrap_or(0); + let patched = photometric_min_is_black(bytes); + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).expect("tiff decode"); + let tiff::decoder::DecodingResult::U8(samples) = dec.read_image().expect("read image") else { + panic!("expected 8-bit samples"); + }; + (spp, bps, extra, rows, samples) +} + +/// Classic-TIFF photometric tag 262 → 1 (MinIsBlack), value stored inline in the IFD entry. +fn photometric_min_is_black(src: &[u8]) -> Vec { + let mut bytes = src.to_vec(); + let le = bytes.first().copied() == Some(b'I'); + let ru16 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 2)?; + Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }) + }; + let ru32 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 4)?; + Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) }) + }; + let Some(ifd) = ru32(&bytes, 4) else { return bytes }; + let Some(n) = ru16(&bytes, ifd as usize) else { return bytes }; + for e in 0..n as usize { + let at = ifd as usize + 2 + e * 12; + if ru16(&bytes, at) == Some(262) { + let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() }; + if let Some(slot) = bytes.get_mut(at + 8..at + 10) { + slot.copy_from_slice(&value); + } + break; + } + } + bytes +} + +fn assert_plane(samples: &[u8], spp: usize, plane: usize, expected: &[u8]) { + assert_eq!(samples.len(), expected.len().saturating_mul(spp)); + let mut bad = 0usize; + let mut first = None; + for (i, want) in expected.iter().enumerate() { + let got = samples.get(i * spp + plane).copied(); + if got != Some(*want) { + bad += 1; + first.get_or_insert((i, got)); + } + } + assert_eq!(bad, 0, "{bad} mismatches, first {first:?}"); +} + +/// A multi-layer transparent document, large enough for several TIFF strips and several +/// raster tiles, with a spot whose every pixel is a known value. +#[test] +fn dtf_like_spot_is_written_in_full() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_psd::tiff::resources_from_bytes; + use photocraft_raster::Surface; + + let (w, h) = (900u32, 360u32); + let mut doc = Document::new("dtf", Size::new(w, h), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + let px = photocraft_raster::from_rgba(&fmt, [0.2, 0.5, 0.9, 1.0]); + art.fill_rect(Rect::new(30, 20, 870, 340), &px); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut ink = Surface::new(fmt); + let px = photocraft_raster::from_rgba(&fmt, [0.9, 0.1, 0.2, 0.5]); + ink.fill_rect(Rect::new(80, 40, 700, 300), &px); + let mut layer = Layer::new("Ink", LayerContent::Raster(ink)); + layer.opacity = 0.8; + doc.layers.push(layer); + + let n = (w as usize) * (h as usize); + let mut spot_bytes = vec![0u8; n]; + for y in 0..h as usize { + for x in 0..w as usize { + spot_bytes[y * w as usize + x] = ((x * 3 + y * 17) % 251) as u8; + } + } + // Outside the artwork the composite is transparent, but the spot is not zero: a reader that + // binds "Spot Color 1" to the alpha plane fails here. + assert_eq!(spot_bytes[10 * w as usize + 10], 200); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let spot_surface = Surface::from_interleaved(gray, Rect::new(0, 0, w as i32, h as i32), &spot_bytes); + let mut channel = AlphaChannel::new("Spot Color 1", spot_surface); + channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(channel); + + let mut seen = Vec::new(); + for (name, layers) in [("flat", false), ("layered", true)] { + let opts = ExportOptions { tiff_layers: layers, ..ExportOptions::default() }; + let r = export(&doc, "dtf.tif", &opts).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{name}: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha(), "{name}: {:?}", img.layout()); + assert_eq!(img.layout().color_channels(), 3, "{name}"); + assert_eq!(img.meta.extra_samples.len(), 1, "{name}"); + assert_eq!(img.meta.extra_samples[0].kind, 0, "{name}"); + // Photoshop stores spot ink inverted: document 255 (solid) is file 0. + let stored_spot: Vec = spot_bytes.iter().map(|b| 255 - b).collect(); + assert_eq!(img.meta.extra_samples[0].data, stored_spot, "{name} own decoder"); + + let (spp, bps, extra, rows, samples) = independent_samples(&r.bytes); + assert_eq!(spp, 4, "{name}"); + assert_eq!(bps, vec![8, 8, 8, 8], "{name}"); + assert_eq!(extra, vec![0], "{name} ExtraSamples"); + assert!(rows > 0 && rows < h, "{name} rows_per_strip {rows}"); + assert_plane(&samples, 4, 3, &stored_spot); + seen.push(samples.clone()); + + // Outside the artwork the composite is matted to white (Save Transparency off). The + // spot at that pixel is not zero, so a reader that binds the name to the alpha fails. + let at = (10 * w as usize + 10) * 4; + assert_eq!(&samples[at..at + 3], &[255, 255, 255], "{name} matted rgb"); + let (resources, layer_tag) = tags(&r.bytes); + let resources = resources.expect("photoshop resources"); + let (blocks, warnings) = resources_from_bytes(&resources).unwrap(); + assert!(warnings.is_empty(), "{name} {warnings:?}"); + let block = |id: u16| blocks.iter().find(|b| b.id == id).unwrap_or_else(|| panic!("{name} missing {id}")); + // "Spot Color 1" is 12 characters: Photoshop stores 13 bytes and pads the 8BIM block, not the string. + assert_eq!(block(1006).data, b"\x0cSpot Color 1", "{name}"); + assert_eq!(block(1045).data.len(), 30, "{name}"); + let info = &block(1077).data; + assert_eq!(info.len(), 17, "{name}"); + assert_eq!(u16::from_be_bytes([info[14], info[15]]), 60, "{name} solidity"); + assert_eq!(info[16], 2, "{name} spot kind"); + // Photoshop numbers a new document's channels from 3. + assert_eq!(block(1053).data, 3u32.to_be_bytes(), "{name} channel id"); + + let back = import("dtf.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.len(), 1, "{name}"); + assert_eq!(back.channels[0].name, "Spot Color 1"); + let (ink, sol) = back.channels[0].spot.expect("spot"); + assert_eq!(ink.to_rgb(), [1.0, 0.0, 0.0], "{name}"); + assert!((sol - 0.6).abs() < 1e-4, "{name}"); + let area = Rect::new(0, 0, w as i32, h as i32); + assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{name} reimport"); + if layers { + let layer_tag = layer_tag.expect("layer tag"); + let (isd, w) = ImageSourceData::from_bytes(&layer_tag).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let info = isd.layer_info.expect("layers"); + assert!(!info.merged_alpha, "{name}"); + assert!(info.layers.iter().all(|l| l.flags.0 & 0x08 != 0), "{name} photoshop flag"); + assert!(info.layers.iter().all(|l| l.blocks.iter().any(|b| &b.key == b"lnsr")), "{name} lnsr"); + let mask = isd.global_layer_mask.expect("LMsk"); + assert_eq!(mask.data.len(), 14, "{name}"); + assert_eq!(mask.opacity(), Some(50)); + assert_eq!(mask.kind(), Some(128)); + assert_photoshop_layered_tiff(&r.bytes, true); + assert_eq!(back.layers.len(), 2, "{name}"); + let ink = back.layers.iter().find(|l| l.name == "Ink").expect("ink layer"); + let px = ink.surface().expect("ink pixels").pixel(100, 100); + assert!((px[3] - 0.5).abs() < 0.02, "{name} layer alpha {px:?}"); + let outside = ink.surface().expect("ink pixels").pixel(0, 0); + assert!(outside[3] < 0.02, "{name} transparent pixel {outside:?}"); + } else { + assert!(layer_tag.is_none(), "{name}"); + } + } + assert_eq!(seen[0], seen[1], "flat and layered IFD samples differ"); +} + +/// A transparent document with no user channels still stores image alpha. +#[test] +fn transparent_tiff_without_channels_keeps_image_alpha() { + use photocraft_doc::Layer; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + for layers in [false, true] { + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha(), "{layers}"); + assert!(img.meta.extra_samples.is_empty(), "{layers}"); + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(r.bytes.as_slice())).unwrap(); + let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap(); + assert_eq!(extra, vec![2], "{layers}"); + assert!(!r.warnings.iter().any(|w| w.contains("Save Transparency")), "{layers}: {:?}", r.warnings); + if layers { + assert_photoshop_layered_tiff(&r.bytes, false); + } + } +} + +/// A flat TIFF that also stores an alpha or spot channel mattes the composite on white +/// and drops image transparency. The export says so. +#[test] +fn flat_tiff_warns_when_extra_channels_matte_transparency() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + // (0, 0) stays transparent. (1, 1) is half-red, so the matte is halfway to white. + art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false))); + spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0)); + doc.channels.push(spot); + + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: false, ..ExportOptions::default() }).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("composited over white") && w.contains("Save Transparency")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha(), "{:?}", img.layout()); + assert_eq!(img.meta.extra_samples.len(), 1); + let (_spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!(extra, vec![0]); + // 4 samples per pixel: RGB + spot. (0, 0) was transparent and is now white. + assert_eq!(&samples[..3], &[255, 255, 255]); + // Half-red over white is about (255, 128, 128). Pixel (1, 1), 4 samples per pixel. + let (x, y, width, spp) = (1usize, 1usize, 4usize, 4usize); + let at = (y * width + x) * spp; + let rgb = &samples[at..at + 3]; + assert!(rgb[0] > 250 && (rgb[1] as i16 - 128).abs() <= 2 && (rgb[2] as i16 - 128).abs() <= 2, "{rgb:?}"); +} + +/// A 4×4 RGB document whose one layer is transparent outside its middle, plus a "White" spot +/// channel with solid ink in its left half: the shape of a UV print's underbase. +fn transparent_art_with_white_spot() -> Document { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.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(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, 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 +} + +/// The first channel's ink at (0, 0), inked, and at (3, 0), bare. +fn ink(d: &Document) -> (f32, f32) { + (d.channels[0].surface.pixel(0, 0)[0], d.channels[0].surface.pixel(3, 0)[0]) +} + +/// `doc` laid out as Photoshop 25.12 saves a TIFF with Save Transparency on: R, G, B, +/// transparency and spot samples, with "Transparency" first in the channel names and display +/// records. With `layers`, the layer data has a positive layer count and an `MTrn` block (the +/// specification's key is `Mtrn`); without, the file is flat. Built from the PSD model of `doc`. +fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec { + use photocraft_psd::{Compression, TaggedBlock}; + + let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() }; + let (file, _) = document_to_psd_with(doc, &opts); + let planes = file.decode_merged().unwrap(); + let n = 16; + assert_eq!(planes.len(), 5 * n); + let rgba: Vec = (0..n).flat_map(|i| (0..4).map(move |c| (i, c))).map(|(i, c)| planes[c * n + i]).collect(); + let mut img = photocraft_codecs::Image::from_raw(4, 4, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, rgba).unwrap(); + img.meta.extra_samples = vec![photocraft_codecs::ExtraSample { kind: 0, data: planes[4 * n..].to_vec() }]; + img.meta.photoshop_resources = Some(photocraft_psd::tiff::resources_to_bytes(&file.resources).unwrap()); + if layers { + let mut isd = ImageSourceData::from_psd(&file); + if let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + isd.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new())); + let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; + img.meta.photoshop_layers = Some(isd.to_bytes(order).unwrap().0); + } + photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap() +} + +/// Photoshop's layered TIFF with transparency next to a spot channel opens with the spot as the +/// only channel. A layered re-save leaves the transparency sample out next to the spot, so it +/// must also drop the `MTrn` marker, or the spot would read back as transparency. +#[test] +fn photoshop_mtrn_tiff_opens_without_a_transparency_channel() { + use photocraft_psd::TaggedBlock; + + let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), true); + let opened = import("t.tif", &bytes).unwrap().document; + let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"]); + assert!(opened.channels[0].spot.is_some()); + assert_eq!(ink(&opened), (1.0, 0.0)); + + let r = tiff(&opened, true); + let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.unwrap()).unwrap(); + assert!(!isd.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker)); + 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"]); + assert_eq!(ink(&back), (1.0, 0.0)); +} + +/// The same Photoshop layout in a flat TIFF (no layer data): the image keeps its transparency, +/// and the spot gets its own name and display record, not the leading "Transparency" ones, so +/// it opens as a spot with its ink the right way round. +#[test] +fn photoshop_flat_tiff_with_transparency_opens_its_spot_as_a_spot() { + let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), false); + assert!(tags(&bytes).1.is_none(), "flat: no layer data"); + let opened = import("t.tif", &bytes).unwrap().document; + let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"]); + assert!(opened.channels[0].spot.is_some(), "a spot, not an alpha channel"); + assert_eq!(ink(&opened), (1.0, 0.0)); + let LayerContent::Raster(s) = &opened.layers[0].content else { panic!("raster") }; + assert_eq!(s.pixel(0, 0)[3], 0.0, "the image keeps its transparency"); +} + +/// A Lab document with a spot channel keeps the spot as a CIELab extra sample, and its +/// transparency is matted on Lab white (L* 100, a* and b* 0), not on the maximum of every sample. +#[test] +fn lab_tiff_keeps_spot_and_mattes_on_lab_white() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + for sample in [SampleType::U8, SampleType::U16] { + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Lab, sample); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + // (0, 0) stays transparent. + art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut plate = Surface::new(PixelFormat::new(ColorMode::Grayscale, sample, false)); + plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&plate.format(), [1.0, 1.0, 1.0, 1.0])); + let mut spot = AlphaChannel::new("Spot Color 1", plate); + spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0)); + doc.channels.push(spot); + + // Lab is always saved flat, whether layers were asked for or not. + for layers in [false, true] { + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Lab, "{sample:?} {layers}"); + assert_eq!(img.meta.extra_samples.len(), 1, "{sample:?} {layers}"); + let white = &img.to_normalized()[..3]; + assert!((white[0] - 1.0).abs() < 1e-3 && (white[1] - 128.0 / 255.0).abs() < 1e-3 && (white[2] - 128.0 / 255.0).abs() < 1e-3, "{white:?}"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.pixel_format().mode, ColorMode::Lab, "{sample:?} {layers}"); + assert_eq!(back.channels.len(), 1, "{sample:?} {layers}"); + assert_eq!(back.channels[0].name, "Spot Color 1"); + assert!(back.channels[0].spot.is_some(), "{sample:?} {layers}"); + let plate = |c: &AlphaChannel| c.surface.to_interleaved(Rect::new(0, 0, 4, 4)); + assert_eq!(plate(&back.channels[0]), plate(&doc.channels[0]), "{sample:?} {layers}"); + } + } +} + +/// Tag 37724 walked as raw bytes (not through [`ImageSourceData`]). +/// +/// Matches a Photoshop 27 layered TIFF: classic 4-byte block lengths, `Layr` then a 14-byte +/// `LMsk`, no `MTrn` (that block is only for a composite that keeps both transparency and a +/// spot). A 4320×4320 RGB spot plate is 74_649_600 bytes and still fits those u32 lengths; +/// this check uses the same layout on a smaller image. +fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) { + assert!(bytes.starts_with(b"II"), "little-endian TIFF"); + let u16_at = |i: usize| u16::from_le_bytes([bytes[i], bytes[i + 1]]); + let u32_at = |i: usize| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap()); + let ifd = u32_at(4) as usize; + let n = u16_at(ifd) as usize; + let mut layers = None; + let mut spp = 0u16; + let mut extra = Vec::new(); + for e in 0..n { + let at = ifd + 2 + e * 12; + let tag = u16_at(at); + let typ = u16_at(at + 2); + let count = u32_at(at + 4) as usize; + let val = u32_at(at + 8) as usize; + let unit: usize = match typ { + 3 => 2, + 4 => 4, + _ => 1, + }; + let size = unit.saturating_mul(count); + let inline = at + 8; + let raw = if size <= 4 { &bytes[inline..inline + size] } else { &bytes[val..val + size] }; + match tag { + 277 => spp = u16::from_le_bytes([raw[0], raw[1]]), + 338 => extra = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(), + 37724 => layers = Some(raw), + _ => {} + } + } + let isd = layers.expect("37724"); + let sig = b"Adobe Photoshop Document Data Block\0"; + assert!(isd.starts_with(sig), "PSB-sized lengths"); + let mut pos = sig.len(); + let mut keys = Vec::new(); + let mut layr: Option> = None; + while pos + 12 <= isd.len() { + assert_eq!(&isd[pos..pos + 4], b"MIB8", "block at {pos}"); + let key = [isd[pos + 7], isd[pos + 6], isd[pos + 5], isd[pos + 4]]; + let len = u32::from_le_bytes(isd[pos + 8..pos + 12].try_into().unwrap()) as usize; + let body = &isd[pos + 12..pos + 12 + len]; + if &key == b"Layr" { + layr = Some(body.to_vec()); + } + if &key == b"LMsk" { + assert_eq!(body, &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0x32, 0, 0x80, 0]); + } + keys.push(key); + pos += 12 + len; + let rest = &isd[pos..]; + let pad = (0..=3).find(|&k| k == rest.len() || rest.get(k..k + 4) == Some(b"MIB8")).unwrap_or(0); + pos += pad; + } + assert_eq!(pos, isd.len(), "trailing bytes after the last block"); + assert_eq!(keys.first(), Some(b"Layr")); + assert_eq!(keys.get(1), Some(b"LMsk")); + assert!(!keys.iter().any(|k| k == b"MTrn" || k == b"Mtrn")); + let layr = layr.expect("Layr"); + let count = i16::from_le_bytes([layr[0], layr[1]]); + if with_spot { + assert!(count > 0, "layer count {count}"); + assert_eq!((spp, extra.as_slice()), (4, [0u16].as_slice())); + } else { + assert!(count < 0, "layer count {count}"); + assert_eq!(extra, vec![2]); + } + let nch = u16::from_le_bytes([layr[18], layr[19]]) as usize; + let mut ids = Vec::new(); + let mut p = 20; + for _ in 0..nch { + ids.push(i16::from_le_bytes([layr[p], layr[p + 1]])); + p += 6; + } + assert!(ids.contains(&-1), "channel ids {ids:?}"); +} + +/// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel +/// has spot 0, and the white background is spot 255. An alpha channel is the other way +/// around (255 = selected). Photoshop's PSDs store both the same way, and so does a PSD saved +/// here. Everything round-trips, PSD → TIFF → PSD included. +#[test] +fn spot_ink_is_stored_as_zero_and_alpha_is_not() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_psd::PsdFile; + use photocraft_raster::Surface; + + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let (w, h) = (8u32, 8u32); + let mut doc = Document::new("pol", Size::new(w, h), ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(2, 2, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.2, 0.1, 1.0])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let n = (w as usize) * (h as usize); + let mut ink = vec![0.0f32; n]; + for y in 2..6 { + for x in 2..6 { + ink[y * w as usize + x] = 1.0; + } + } + let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(gray)); + spot.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink); + spot.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(spot); + let mut alpha = AlphaChannel::new("Alpha 1", Surface::new(gray)); + alpha.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink); + doc.channels.push(alpha); + + let psd = export(&doc, "pol.psd", &ExportOptions::default()).unwrap(); + let file = PsdFile::from_bytes(&psd.bytes).unwrap(); + let merged = file.decode_merged().unwrap(); + let bps = depth.bytes(); + let plane = n * bps; + let first = 3 + usize::from(file.merged_has_alpha()); + let spot_plane = &merged[first * plane..(first + 1) * plane]; + let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane]; + // Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper. + // Alpha: max = selected, 0 = empty. + assert_stored(spot_plane, 0, depth, false, "psd spot background"); + assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art"); + assert_stored(alpha_plane, 0, depth, true, "psd alpha background"); + assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art"); + // The merged transparency (0), then the spot and the alpha channel (3, 4). + assert_eq!(file.resource(1053).map(|r| identifiers(&r.data)), Some(vec![0, 3, 4]), "{depth:?}"); + let back = import("pol.psd", &psd.bytes).unwrap().document; + let area = Rect::new(0, 0, w as i32, h as i32); + assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{depth:?} psd spot"); + assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{depth:?} psd alpha"); + assert!(back.channels[0].spot.is_some(), "{depth:?}"); + assert!(back.channels[1].spot.is_none(), "{depth:?}"); + + if depth == SampleType::U8 { + for layers in [false, true] { + // The document as opened from the PSD, so the PSD's own resources come along. + let r = export(&back, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!((spp, extra.as_slice()), (5, [0u16, 0].as_slice()), "{layers}"); + // Reference TIFF: artwork spot sample is 0, background spot sample is 255. + assert_eq!(samples[3], 255, "{layers} background spot"); + assert_eq!(samples[(3 * w as usize + 3) * 5 + 3], 0, "{layers} art spot"); + assert_eq!(samples[4], 0, "{layers} background alpha"); + assert_eq!(samples[(3 * w as usize + 3) * 5 + 4], 255, "{layers} art alpha"); + // One identifier per extra sample, numbered from 3 (no transparency sample). + assert_eq!(tiff_alpha_identifiers(&r.bytes), [3, 4], "{layers}"); + let from_tiff = import("pol.tif", &r.bytes).unwrap().document; + assert_eq!(from_tiff.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); + assert_eq!(from_tiff.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); + assert!(from_tiff.channels[0].spot.is_some() && from_tiff.channels[1].spot.is_none(), "{layers}"); + // Saved as PSD again: the first PSD's planes, and one 1053 of its own. The flat TIFF's + // composite was matted on white, so only the layered one brings transparency back. + let psd = PsdFile::from_bytes(&export(&from_tiff, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + let again = psd.decode_merged().unwrap(); + assert_eq!(again[again.len() - 2 * plane..], merged[(first * plane)..(first + 2) * plane], "{layers} tiff to psd"); + assert_eq!(psd.merged_has_alpha(), layers, "{layers} tiff to psd"); + let want: Vec = if layers { vec![0, 3, 4] } else { vec![3, 4] }; + assert_eq!(psd.resources.iter().filter(|r| r.id == 1053).map(|r| identifiers(&r.data)).collect::>(), [want], "{layers} tiff to psd"); + let reopened = import("pol.psd", &psd.to_bytes().unwrap()).unwrap().document; + assert_eq!(reopened.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); + assert_eq!(reopened.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); + } + } + } +} + +/// The big-endian u32s of a resource 1053. +fn identifiers(data: &[u8]) -> Vec { + data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect() +} + +/// The identifiers in a TIFF's resource 1053 (none without one). +fn tiff_alpha_identifiers(bytes: &[u8]) -> Vec { + let resources = tags(bytes).0.expect("photoshop resources"); + let (blocks, _) = photocraft_psd::tiff::resources_from_bytes(&resources).unwrap(); + blocks.iter().find(|b| b.id == 1053).map(|b| identifiers(&b.data)).unwrap_or_default() +} + +/// `at` addresses one sample of a big-endian PSD plane. `zero` expects 0; otherwise the +/// maximum sample (255 / 65535 / 1.0). +fn assert_stored(plane: &[u8], at: usize, depth: SampleType, zero: bool, what: &str) { + match depth { + SampleType::U8 => { + let v = plane[at]; + assert_eq!(v, if zero { 0 } else { 255 }, "{what} {v}"); + } + SampleType::U16 => { + let v = u16::from_be_bytes([plane[at], plane[at + 1]]); + assert_eq!(v, if zero { 0 } else { 65535 }, "{what} {v}"); + } + SampleType::F32 => { + let v = f32::from_be_bytes([plane[at], plane[at + 1], plane[at + 2], plane[at + 3]]); + assert_eq!(v, if zero { 0.0 } else { 1.0 }, "{what} {v}"); + } + } +} + +/// Quick Mask stays the last channel after the merged transparency plane is omitted. +#[test] +fn spot_tiff_keeps_quick_mask_when_composite_alpha_is_omitted() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("q", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let area = Rect::new(0, 0, 8, 8); + let mut spot_bytes = vec![9u8; 64]; + spot_bytes[0] = 3; + let mut channel = AlphaChannel::new("Spot Color 1", Surface::from_interleaved(gray, area, &spot_bytes)); + channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(channel); + let mut qbytes = vec![7u8; 64]; + qbytes[1] = 4; + doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::from_interleaved(gray, area, &qbytes))); + + for layers in [false, true] { + let r = export(&doc, "q.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!((spp, extra.as_slice()), (5, [0, 0].as_slice()), "{layers}"); + let stored_spot: Vec = spot_bytes.iter().map(|b| 255 - b).collect(); + assert_plane(&samples, 5, 3, &stored_spot); + // A Quick Mask is an alpha channel: stored straight, not as spot ink. + assert_plane(&samples, 5, 4, &qbytes); + let back = import("q.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.len(), 1, "{layers}"); + assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{layers}"); + let qm = back.quick_mask.as_ref().unwrap_or_else(|| panic!("{layers}: no quick mask")); + assert_eq!(qm.surface.to_interleaved(area), qbytes, "{layers}"); + } +} + +#[test] +fn default_export_options_write_a_flat_tiff() { + // Scripted and agent saves (CLI, batch, MCP) build on the defaults: layers only on request. + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + assert!(tiff_layers::would_write_layers(&d)); + let r = export(&d, "x.tif", &ExportOptions::default()).unwrap(); + assert_eq!(tags(&r.bytes), (None, None)); + assert_eq!(import("x.tif", &r.bytes).unwrap().document.layers.len(), 1); +} + +/// #2940: layered TIFF data whose groups nest past the document's cap is rejected with the same +/// limit error as a PSD, instead of being converted. +#[test] +fn layered_tiff_nested_past_the_cap_is_rejected() { + use photocraft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder, PsdFile}; + let tiff_nested = |levels: usize| { + let mut b = PsdBuilder::new(4, 4); + for _ in 0..levels { + b.begin_group(GroupSpec::new("g")); + } + b.push_layer(LayerSpec::new("deep", 0, 0, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4]))); + for _ in 0..levels { + b.end_group().unwrap(); + } + let file = PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap(); + let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; + let layers = ImageSourceData::from_psd(&file).to_bytes(order).unwrap().0; + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let mut img = photocraft_codecs::decode(&tiff(&d, false).bytes).unwrap(); + img.meta.photoshop_layers = Some(layers); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap() + }; + let err = import("deep.tif", &tiff_nested(101)).unwrap_err(); + assert!(err.to_string().contains("nested deeper than 100"), "{err}"); + let r = import("hundred.tif", &tiff_nested(100)).unwrap(); + assert_eq!(r.document.max_group_depth(), 100); +} + +#[test] +fn layered_tiff_writes_photoshops_global_layer_mask() { + // #9095: Photoshop reads only the composite of a layered TIFF whose `LMsk` block is empty + // ("There was a problem reading the layer data"); it writes 14 bytes there itself: overlay + // colour space 0, colour (65535, 0, 0, 0), opacity 50, kind 128, one filler byte. + for depth in [SampleType::U8, SampleType::U16] { + let d = gen_doc(ColorMode::Rgb, depth, Features::PIXELS); + let (_, layers) = tags(&tiff(&d, true).bytes); + let (isd, _) = ImageSourceData::from_bytes(&layers.expect("layer data")).expect("parse layer data"); + let mask = isd.global_layer_mask.expect("LMsk block"); + assert_eq!(mask.data, [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0], "{depth:?}"); + } +} + +/// Past MAX_EXTRA_SAMPLES (16), a layered TIFF stores the first 16 extra channels. Its channel +/// resources must describe those 16 and no more: 16 names in 1006 and 1045, 16 DisplayInfo +/// records and 16 identifiers, and no Quick Mask record for a Quick Mask that wasn't stored. +#[test] +fn channels_past_sixteen_leave_no_resources_behind() { + use photocraft_color::PixelFormat; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(0, 0, 8, 8), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + 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))); + } + doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::new(gray))); + + let r = tiff(&doc, true); + assert!(r.warnings.iter().any(|w| w.contains("19 extra channels") && w.contains("first 16")), "{:?}", r.warnings); + let (resources, _) = tags(&r.bytes); + let (resources, _) = photocraft_psd::tiff::resources_from_bytes(&resources.expect("resources")).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, 0usize); + while let Some(&len) = pascal.get(at) { + at += 1 + usize::from(len); + names += 1; + } + assert_eq!(names, 16, "1006"); + let unicode = data(1045); + let (mut at, mut names) = (0usize, 0usize); + while let Some(b) = unicode.get(at..at + 4) { + at += 4 + 2 * u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize; + names += 1; + } + assert_eq!(names, 16, "1045"); + assert_eq!(data(1077).len(), 4 + 16 * 13, "1077"); + assert_eq!(data(1053).len(), 16 * 4, "1053"); + assert!(resources.iter().all(|r| r.id != 1022), "the Quick Mask was not stored, so its record goes"); + + let flat = photocraft_codecs::decode(&r.bytes).expect("decode"); + assert_eq!(flat.meta.extra_samples.len(), 16); + let back = import("x.tif", &r.bytes).expect("import").document; + let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect(); + let want: Vec = (1..=16).map(|i| format!("Alpha {i}")).collect(); + assert_eq!(names, want.iter().map(String::as_str).collect::>()); + assert!(back.quick_mask.is_none()); +} diff --git a/crates/io/tests/tiff_pages.rs b/crates/io/tests/it/tiff_pages.rs similarity index 100% rename from crates/io/tests/tiff_pages.rs rename to crates/io/tests/it/tiff_pages.rs diff --git a/crates/io/tests/tiff_type_geometry.rs b/crates/io/tests/it/tiff_type_geometry.rs similarity index 62% rename from crates/io/tests/tiff_type_geometry.rs rename to crates/io/tests/it/tiff_type_geometry.rs index 8a04c2dc97..326ec35cee 100644 --- a/crates/io/tests/tiff_type_geometry.rs +++ b/crates/io/tests/it/tiff_type_geometry.rs @@ -5,9 +5,14 @@ use photocraft_doc::text::{CharStyle, TextRun}; use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer}; use photocraft_geom::Affine; use photocraft_io::{ExportOptions, PsdExportOptions, document_to_psd_with, export, import, psd_to_document}; +use photocraft_psd::descriptor::{Value, VersionedDescriptor}; fn document() -> Document { - let mut doc = Document::new("Synthetic type", Size::new(640, 400), ColorMode::Rgb, SampleType::U8); + document_in(ColorMode::Rgb, SampleType::U8) +} + +fn document_in(mode: ColorMode, depth: SampleType) -> Document { + let mut doc = Document::new("Synthetic type", Size::new(640, 400), mode, depth); doc.resolution_dpi = 300.0; let mut t = TextLayer { text: "Synthetic".into(), @@ -46,6 +51,49 @@ fn photoshop_mixed_order_tiff_import_keeps_editable_geometry_and_pixels() { assert_eq!(rerendered.content_bounds(), after.cache.as_ref().unwrap().content_bounds()); } +#[test] +fn layered_tiff_export_keeps_tysh_text_descriptor_big_endian() { + for (mode, depth) in [ + (ColorMode::Rgb, SampleType::U8), + (ColorMode::Rgb, SampleType::U16), + (ColorMode::Rgb, SampleType::F32), + (ColorMode::Grayscale, SampleType::U8), + (ColorMode::Grayscale, SampleType::U16), + (ColorMode::Grayscale, SampleType::F32), + (ColorMode::Cmyk, SampleType::U8), + (ColorMode::Cmyk, SampleType::U16), + ] { + let doc = document_in(mode, depth); + let (psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default()); + let canonical = &psd.layers()[0].block(b"TySh").unwrap().data; + let encoded = export(&doc, "test.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap(); + let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&encoded.bytes); + let layers = layers.expect("layered TIFF tag 37724"); + let block_at = layers.windows(8).position(|window| window == b"MIB8hSyT").expect("little-endian TySh block"); + let length_at = block_at + 8; + let length_bytes: [u8; 4] = layers.get(length_at..length_at + 4).expect("TySh length").try_into().expect("four-byte length"); + let length = usize::try_from(u32::from_le_bytes(length_bytes)).expect("test block length fits usize"); + let data_at = length_at + 4; + let payload = layers.get(data_at..data_at + length).expect("TySh payload"); + let (_, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap(); + let text_section_len = 2 + descriptor_len; + assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..]), "{mode:?} {depth:?}"); + assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..]), "{mode:?} {depth:?}"); + assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len), "{mode:?} {depth:?}"); + let descriptor = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0; + assert!(matches!(descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == doc.layers[0].name)); + + let opened = import("photoshop.tif", &encoded.bytes).unwrap(); + assert!(!opened.warnings.iter().any(|warning| warning.contains("TySh")), "{:?}", opened.warnings); + let LayerContent::Text(before) = &doc.layers[0].content else { panic!("source type") }; + let LayerContent::Text(after) = &opened.document.layers[0].content else { panic!("imported type") }; + assert_eq!(after.transform, before.transform, "{mode:?} {depth:?}"); + assert_eq!(after.size_pt, before.size_pt, "{mode:?} {depth:?}"); + assert_eq!(after.text, before.text, "{mode:?} {depth:?}"); + assert_eq!(after.cache.as_ref().unwrap().content_bounds(), before.cache.as_ref().unwrap().content_bounds(), "{mode:?} {depth:?}"); + } +} + #[test] fn foreign_or_unsafe_type_keeps_cached_pixels_without_editable_geometry() { let doc = document(); diff --git a/crates/io/tests/vector.rs b/crates/io/tests/it/vector.rs similarity index 90% rename from crates/io/tests/vector.rs rename to crates/io/tests/it/vector.rs index e32cbed3fa..71bf66823c 100644 --- a/crates/io/tests/vector.rs +++ b/crates/io/tests/it/vector.rs @@ -135,6 +135,37 @@ fn shape_layer_typed_roundtrip() { } } +/// #6180: Appearance fill and stroke alphas survive PSD save/load, and a redraw +/// (move/crop) uses those values instead of snapping to opaque. +#[test] +fn rectangle_appearance_alphas_survive_psd_and_redraw() { + let mut d = doc(); + let sh = ShapeLayer { + path: photocraft_vector::shapes::rect(16.0, 16.0, 128.0, 96.0), + fill: Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 0.5))), + stroke: Some(ShapeStroke { width: 4.0, paint: Fill::Solid(Color::rgba(0.0, 0.0, 1.0, 0.4)), opacity: 0.5, ..ShapeStroke::default() }), + live: Some(LiveShape::Rect { rect: [16.0, 16.0, 128.0, 96.0], radii: [0.0; 4] }), + ..Default::default() + }; + d.layers.push(shape_layer(sh)); + let b = roundtrip(&d); + let LayerContent::Shape(s) = &b.layers[1].content else { panic!("not a shape") }; + let Fill::Solid(fill) = s.fill.as_ref().unwrap() else { panic!("solid fill") }; + assert!((fill.alpha - 0.5).abs() < 1e-4, "fill appearance alpha {}", fill.alpha); + let st = s.stroke.as_ref().unwrap(); + let Fill::Solid(stroke_c) = &st.paint else { panic!("solid stroke") }; + assert!((stroke_c.alpha - 0.4).abs() < 1e-4, "stroke colour alpha {}", stroke_c.alpha); + assert!((st.opacity - 0.5).abs() < 1e-4, "stroke opacity {}", st.opacity); + let mut redrawn = s.clone(); + redrawn.cache = Some(photocraft_vector::render_shape(&redrawn, photocraft_color::PixelFormat::RGBA8, Rect::new(0, 0, 256, 256))); + let Fill::Solid(fill2) = redrawn.fill.as_ref().unwrap() else { panic!("solid fill") }; + assert!((fill2.alpha - 0.5).abs() < 1e-4); + let st2 = redrawn.stroke.as_ref().unwrap(); + let Fill::Solid(stroke2) = &st2.paint else { panic!("solid stroke") }; + assert!((stroke2.alpha - 0.4).abs() < 1e-4); + assert!((st2.opacity - 0.5).abs() < 1e-4); +} + #[test] fn custom_stroke_styles_roundtrip_through_psd_vstk() { for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { diff --git a/crates/io/tests/xcf.rs b/crates/io/tests/it/xcf.rs similarity index 100% rename from crates/io/tests/xcf.rs rename to crates/io/tests/it/xcf.rs diff --git a/crates/io/tests/xmp_export.rs b/crates/io/tests/it/xmp_export.rs similarity index 98% rename from crates/io/tests/xmp_export.rs rename to crates/io/tests/it/xmp_export.rs index 8d22017ff6..08af0006c7 100644 --- a/crates/io/tests/xmp_export.rs +++ b/crates/io/tests/it/xmp_export.rs @@ -17,7 +17,7 @@ fn doc_with_xmp() -> Document { let mut s = Surface::new(d.pixel_format()); s.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 6), &[0.5, 0.4, 0.3, 1.0]); d.layers.push(Layer::new("flat", LayerContent::Raster(s))); - d.metadata.xmp = Some(XMP.to_string()); + d.metadata.xmp = Some(XMP.into()); d } diff --git a/crates/io/tests/support/recovery_psd.rs b/crates/io/tests/support/recovery_psd.rs new file mode 100644 index 0000000000..4d26bec646 --- /dev/null +++ b/crates/io/tests/support/recovery_psd.rs @@ -0,0 +1,196 @@ +//! Minimal, deterministic PSD fixtures for the PSD recovery tests. +//! +//! Pure Rust / std: no binary fixtures, external assets, Python, or build script. +//! The damaged fixture has valid layer records and a valid merged composite; +//! only the green channel of the orange layer contains a truncated PackBits run. + +const WIDTH: usize = 128; +const HEIGHT: usize = 96; +const PANEL: (usize, usize, usize, usize) = (20, 16, 108, 80); +const PANEL_RGB: [u8; 3] = [244, 156, 28]; +const PANEL_ALPHA: u8 = 220; + +struct Channel { + id: i16, + data: Vec, +} + +struct Layer { + record: Vec, + pixels: Vec, +} + +/// Writes the big-endian 32-bit length of a PSD section to the output buffer. +fn put_len(out: &mut Vec, len: usize) { + out.extend_from_slice(&u32::try_from(len).expect("test PSD section fits u32").to_be_bytes()); +} + +/// Builds the three RGB planes of the deterministic blue checkerboard background. +fn background_rgb() -> [Vec; 3] { + let mut planes: [Vec; 3] = std::array::from_fn(|_| Vec::with_capacity(WIDTH * HEIGHT)); + for y in 0..HEIGHT { + for x in 0..WIDTH { + let checker = u8::try_from((x / 16 + y / 16) % 2).expect("checker value is 0 or 1"); + planes[0].push(34 + checker * 12); + planes[1].push(102 + checker * 15); + planes[2].push(184 + checker * 12); + } + } + planes +} + +/// Encodes a layer channel without compression, including its PSD compression marker. +fn raw_channel(id: i16, pixels: &[u8]) -> Channel { + let mut data = Vec::with_capacity(2 + pixels.len()); + data.extend_from_slice(&0u16.to_be_bytes()); // 0 = uncompressed + data.extend_from_slice(pixels); + Channel { id, data } +} + +/// Encodes constant-color PackBits rows, optionally truncating the middle row. +/// The corrupt variant keeps the row-length table structurally valid. +fn rle_channel(id: i16, sample: u8, width: usize, height: usize, corrupt: bool) -> Channel { + let mut rows: Vec> = Vec::with_capacity(height); + for y in 0..height { + if corrupt && y == height / 2 { + // 0 declares one literal byte, but that byte is missing. + // Its row-length entry remains valid: this tests the decoder. + rows.push(vec![0]); + continue; + } + let mut row = Vec::new(); + let mut remaining = width; + while remaining != 0 { + let count = remaining.min(128); + // PackBits negative repeat count -(count - 1), modulo 256. + let header = u8::try_from(257 - count).expect("PackBits repeat header fits u8"); + row.extend_from_slice(&[header, sample]); + remaining -= count; + } + rows.push(row); + } + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_be_bytes()); // 1 = PackBits/RLE + for row in &rows { + data.extend_from_slice(&u16::try_from(row.len()).expect("row fits PSD u16 length").to_be_bytes()); + } + for row in rows { + data.extend_from_slice(&row); + } + Channel { id, data } +} + +/// Serializes a PSD layer record and its channel payloads separately, in file order. +fn layer(name: &str, rect: (i32, i32, i32, i32), channels: Vec) -> Layer { + let (left, top, right, bottom) = rect; + let name_bytes = name.as_bytes(); + let name_len = u8::try_from(name_bytes.len()).expect("layer name fits Pascal string"); + + let mut extra = Vec::new(); + extra.extend_from_slice(&0u32.to_be_bytes()); // no layer mask + extra.extend_from_slice(&0u32.to_be_bytes()); // no blending ranges + extra.push(name_len); + extra.extend_from_slice(name_bytes); + while extra.len() % 4 != 0 { + extra.push(0); + } + + let mut record = Vec::new(); + for coordinate in [top, left, bottom, right] { + record.extend_from_slice(&coordinate.to_be_bytes()); + } + record.extend_from_slice(&u16::try_from(channels.len()).expect("channel count fits u16").to_be_bytes()); + for channel in &channels { + record.extend_from_slice(&channel.id.to_be_bytes()); + put_len(&mut record, channel.data.len()); + } + record.extend_from_slice(b"8BIMnorm"); + record.extend_from_slice(&[255, 0, 0, 0]); // opacity, clipping, flags, filler + put_len(&mut record, extra.len()); + record.extend_from_slice(&extra); + + let mut pixels = Vec::new(); + for channel in channels { + pixels.extend_from_slice(&channel.data); + } + Layer { record, pixels } +} + +/// Composites the orange rectangle over the checkerboard as an uncompressed +/// merged RGB image, independent of the deliberately damaged layer channel. +fn merged_rgb(background: &[Vec; 3]) -> Vec { + let mut planes = background.clone(); + for y in PANEL.1..PANEL.3 { + for x in PANEL.0..PANEL.2 { + let pixel = y * WIDTH + x; + for channel in 0..3 { + let fg = u32::from(PANEL_RGB[channel]); + let bg = u32::from(background[channel][pixel]); + let alpha = u32::from(PANEL_ALPHA); + let mixed = (fg * alpha + bg * (255 - alpha) + 127) / 255; + planes[channel][pixel] = u8::try_from(mixed).expect("composite pixel fits u8"); + } + } + } + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_be_bytes()); // merged composite: uncompressed + for plane in &planes { + data.extend_from_slice(plane); + } + data +} + +/// Returns a complete PSD file in memory. `corrupt` changes *only* one RLE row +/// in the green channel of the upper layer; the file is otherwise well framed. +pub fn make_psd(corrupt: bool) -> Vec { + let background = background_rgb(); + let background_layer = + layer("SAFE blue background", (0, 0, WIDTH as i32, HEIGHT as i32), (0..3).map(|ch| raw_channel(ch, &background[ch as usize])).collect()); + + let panel_width = PANEL.2 - PANEL.0; + let panel_height = PANEL.3 - PANEL.1; + let top_layer = layer( + "BROKEN orange RLE", + (PANEL.0 as i32, PANEL.1 as i32, PANEL.2 as i32, PANEL.3 as i32), + vec![ + rle_channel(0, PANEL_RGB[0], panel_width, panel_height, false), + rle_channel(1, PANEL_RGB[1], panel_width, panel_height, corrupt), + rle_channel(2, PANEL_RGB[2], panel_width, panel_height, false), + rle_channel(-1, PANEL_ALPHA, panel_width, panel_height, false), + ], + ); + // Photoshop stores the top layer's record first. + let layers = [top_layer, background_layer]; + let mut layer_info = Vec::new(); + layer_info.extend_from_slice(&2i16.to_be_bytes()); + for layer in &layers { + layer_info.extend_from_slice(&layer.record); + } + for layer in &layers { + layer_info.extend_from_slice(&layer.pixels); + } + if layer_info.len() % 2 != 0 { + layer_info.push(0); // layer info is padded to an even size + } + + let mut layer_and_mask = Vec::new(); + put_len(&mut layer_and_mask, layer_info.len()); + layer_and_mask.extend_from_slice(&layer_info); + layer_and_mask.extend_from_slice(&0u32.to_be_bytes()); // empty global layer mask + + let mut out = Vec::new(); + out.extend_from_slice(b"8BPS"); + out.extend_from_slice(&1u16.to_be_bytes()); // PSD (not PSB) + out.extend_from_slice(&[0u8; 6]); + out.extend_from_slice(&3u16.to_be_bytes()); // RGB channels + out.extend_from_slice(&(HEIGHT as u32).to_be_bytes()); + out.extend_from_slice(&(WIDTH as u32).to_be_bytes()); + out.extend_from_slice(&8u16.to_be_bytes()); // 8 bits per channel + out.extend_from_slice(&3u16.to_be_bytes()); // RGB colour mode + out.extend_from_slice(&0u32.to_be_bytes()); // no colour-mode data + out.extend_from_slice(&0u32.to_be_bytes()); // no image resources + put_len(&mut out, layer_and_mask.len()); + out.extend_from_slice(&layer_and_mask); + out.extend_from_slice(&merged_rgb(&background)); + out +} diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs deleted file mode 100644 index 56e51bf9f9..0000000000 --- a/crates/io/tests/tiff_layers.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! Layered TIFF (Photoshop layer data in tags 37724 / 34377): Document → TIFF → Document round -//! trips in every mode and depth, equivalence with the PSD path, the flat fall-backs, files -//! written by an independent implementation (psdtags, both byte orders), and malformed input. - -mod common; - -use common::*; -use photocraft_color::{ColorMode, SampleType}; -use photocraft_doc::{Document, LayerContent}; -use photocraft_io::*; -use photocraft_psd::tiff::{ByteOrder, ImageSourceData}; - -fn tiff(doc: &Document, layers: bool) -> ExportResult { - export(doc, "x.tif", &ExportOptions { tiff_layers: layers, ..Default::default() }).expect("export") -} - -/// The two Photoshop tags of a TIFF: (resources, layers). -fn tags(bytes: &[u8]) -> (Option>, Option>) { - let (r, l) = photocraft_codecs::tiff_photoshop_tags(bytes); - (r.map(<[u8]>::to_vec), l.map(<[u8]>::to_vec)) -} - -fn roundtrip(doc: &Document) -> Document { - let r = tiff(doc, true); - let (resources, layers) = tags(&r.bytes); - let layers = layers.expect("layer data"); - assert!(resources.is_some_and(|r| !r.is_empty()), "image resources"); - // The layer data matches the container's byte order and is byte-stable through the psd crate. - let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (isd, w) = ImageSourceData::from_bytes(&layers).expect("parse layer data"); - assert!(w.is_empty(), "{w:?}"); - assert_eq!(isd.byte_order, order); - assert_eq!(isd.to_bytes(order).unwrap().0, layers); - // The TIFF is a plain image to any other reader: the composite at the document's size. - let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode"); - assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height)); - // A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not - // kept, and the export says so. - let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some()); - assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings); - import("x.tif", &r.bytes).expect("import").document -} - -/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask. -fn expected(doc: &Document) -> Document { - let mut d = doc.clone(); - d.channels.clear(); - d.quick_mask = None; - d -} - -macro_rules! rt_case { - ($name:ident, $mode:expr, $depth:expr, $f:expr) => { - #[test] - fn $name() { - let d = gen_doc($mode, $depth, $f); - let back = roundtrip(&d); - assert_docs_eq(&expected(&d), &back); - } - }; -} - -rt_case!(rt_rgb8_all, ColorMode::Rgb, SampleType::U8, Features::ALL); -rt_case!(rt_rgb16_all, ColorMode::Rgb, SampleType::U16, Features::ALL); -rt_case!(rt_rgb32_all, ColorMode::Rgb, SampleType::F32, Features::ALL); -rt_case!(rt_gray8_all, ColorMode::Grayscale, SampleType::U8, Features::ALL); -rt_case!(rt_gray16_all, ColorMode::Grayscale, SampleType::U16, Features::ALL); -rt_case!(rt_gray32_all, ColorMode::Grayscale, SampleType::F32, Features::ALL); -rt_case!(rt_cmyk8_all, ColorMode::Cmyk, SampleType::U8, Features::ALL); -rt_case!(rt_cmyk16_all, ColorMode::Cmyk, SampleType::U16, Features::ALL); -rt_case!(rt_rgb8_pixels, ColorMode::Rgb, SampleType::U8, Features::PIXELS); -rt_case!(rt_gray16_pixels, ColorMode::Grayscale, SampleType::U16, Features::PIXELS); -rt_case!(rt_cmyk8_pixels, ColorMode::Cmyk, SampleType::U8, Features::PIXELS); - -#[test] -fn tiff_and_psd_open_as_the_same_document() { - for (mode, depth) in [(ColorMode::Rgb, SampleType::U8), (ColorMode::Cmyk, SampleType::U16), (ColorMode::Grayscale, SampleType::F32)] { - let d = gen_doc(mode, depth, Features::ALL); - let psd = export(&d, "x.psd", &ExportOptions::default()).unwrap(); - let from_psd = import("x.psd", &psd.bytes).unwrap().document; - let from_tif = roundtrip(&d); - assert_docs_eq(&expected(&from_psd), &from_tif); - // The composite the TIFF carries is what the document looks like. - let tif = tiff(&d, true); - let flat = import( - "flat.tif", - &photocraft_codecs::encode(&photocraft_codecs::decode(&tif.bytes).unwrap(), photocraft_codecs::Format::Tiff, &Default::default()).unwrap(), - ) - .unwrap() - .document; - let a = photocraft_compose::flatten(&d).px; - let b = photocraft_compose::flatten(&flat).px; - assert!(max_diff(&a, &b) <= 2.0 / 255.0, "{mode:?} {depth:?}: composite differs by {}", max_diff(&a, &b)); - } -} - -#[test] -fn every_compression_keeps_the_layers() { - use photocraft_codecs::TiffCompression; - let d = gen_doc(ColorMode::Rgb, SampleType::U16, Features::PIXELS); - for c in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { - let mut opts = ExportOptions { tiff_layers: true, ..Default::default() }; - opts.encode.tiff_compression = c; - let r = export(&d, "x.tiff", &opts).unwrap(); - let back = import("x.tiff", &r.bytes).unwrap().document; - assert_docs_eq(&d, &back); - } -} - -/// The Artist tag's text, and the bytes every EXIF assertion looks for. -const ARTIST: &[u8] = b"fixture-artist"; - -/// A minimal little-endian EXIF (TIFF) block with a single Artist tag and no Orientation, so -/// `upright_exif` passes it through untouched. -fn exif() -> Vec { - let mut artist = ARTIST.to_vec(); - artist.push(0); // ASCII NUL terminator - let mut v = b"II*\0\x08\0\0\0".to_vec(); // TIFF header, first IFD at 8 - v.extend_from_slice(&1u16.to_le_bytes()); // one entry - v.extend_from_slice(&0x013bu16.to_le_bytes()); // Artist - v.extend_from_slice(&2u16.to_le_bytes()); // ASCII - v.extend_from_slice(&(artist.len() as u32).to_le_bytes()); - v.extend_from_slice(&26u32.to_le_bytes()); // value offset: 8 + 2 + 12 + 4 - v.extend_from_slice(&0u32.to_le_bytes()); // no next IFD - v.extend_from_slice(&artist); - v -} - -/// #1545: a layered TIFF carries EXIF as Photoshop resource 1058 and reads it back, so it must -/// not report a loss it never makes. A flat TIFF writes it as its own tags and keeps it too. -#[test] -fn layered_tiff_keeps_exif_without_claiming_a_loss() { - let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); - d.metadata.exif = Some(std::sync::Arc::new(exif())); - - let r = tiff(&d, true); - assert!(!r.warnings.iter().any(|w| w.contains("EXIF")), "a layered TIFF keeps EXIF, so it must not claim to drop it: {:?}", r.warnings); - // ...and the tags really are in the file, so the missing warning is a true absence of loss. - let back = import("x.tif", &r.bytes).unwrap().document; - let kept = back.metadata.exif.as_deref().expect("EXIF survives the layered export"); - assert!(kept.windows(ARTIST.len()).any(|w| w == ARTIST), "the Artist tag did not survive: {kept:?}"); - - // The flat path writes the EXIF block's tags as the TIFF's own (here IFD0's Artist, which - // reads back as the document's Artist text), so it reports no loss either. - let flat = tiff(&d, false); - assert!(!flat.warnings.iter().any(|w| w.contains("EXIF")), "a flat TIFF keeps EXIF: {:?}", flat.warnings); - let back = import("x.tif", &flat.bytes).unwrap().document; - let artist = std::str::from_utf8(ARTIST).unwrap().trim_end_matches('\0'); - assert!(back.metadata.text.iter().any(|(k, v)| k == "Artist" && v == artist), "{:?}", back.metadata.text); -} - -#[test] -fn discard_layers_writes_a_flat_file() { - let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); - let r = tiff(&d, false); - assert_eq!(tags(&r.bytes), (None, None)); - assert!(r.warnings.iter().any(|w| w.contains("flattened")), "{:?}", r.warnings); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_eq!(back.layers.len(), 1); - assert!(!tiff_layers::would_write_layers(&back)); -} - -#[test] -fn a_plain_background_needs_no_layer_data() { - let d = import("x.png", &export(&gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS), "x.png", &Default::default()).unwrap().bytes).unwrap().document; - assert_eq!(d.layers.len(), 1); - assert!(!tiff_layers::would_write_layers(&d)); - let r = tiff(&d, true); - assert_eq!(tags(&r.bytes), (None, None)); - assert!(r.warnings.is_empty(), "{:?}", r.warnings); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_docs_eq(&d, &back); -} - -#[test] -fn lab_documents_are_saved_flat_with_a_warning() { - let d = gen_doc(ColorMode::Lab, SampleType::U8, Features::PIXELS); - let r = tiff(&d, true); - assert_eq!(tags(&r.bytes), (None, None)); - assert!(r.warnings.iter().any(|w| w.contains("Lab") && w.contains("layers are not kept")), "{:?}", r.warnings); - assert!(import("x.tif", &r.bytes).is_ok()); -} - -#[test] -fn transparency_is_straight_in_the_composite() { - // A translucent layer over nothing: the TIFF's composite keeps its colour under the alpha - // instead of being matted against white like a PSD's merged image. - let mut d = Document::new("t", photocraft_geom::Size::new(4, 4), ColorMode::Rgb, SampleType::U8); - let fmt = d.pixel_format(); - let mut l = raster("Red", fmt, photocraft_geom::Rect::new(0, 0, 4, 4), 0, false); - if let LayerContent::Raster(s) = &mut l.content { - let px: Vec = (0..16).flat_map(|_| [1.0, 0.0, 0.0, 0.5]).collect(); - s.write_region(photocraft_geom::Rect::new(0, 0, 4, 4), &px); - } - d.layers.push(l); - let r = tiff(&d, true); - let flat = photocraft_codecs::decode(&r.bytes).unwrap(); - assert!(flat.layout().has_alpha()); - let px = flat.data(); - assert_eq!(&px[..4], &[255, 0, 0, 128]); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_docs_eq(&d, &back); -} - -#[test] -fn big_documents_use_the_psb_signature() { - // Forcing PSB stands in for a > 30000 px document. - let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); - let r = export(&d, "x.tif", &ExportOptions { force_psb: true, tiff_layers: true, ..Default::default() }).unwrap(); - let (_, layers) = tags(&r.bytes); - assert!(layers.unwrap().starts_with(photocraft_psd::tiff::SIGNATURE_PSB)); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_docs_eq(&d, &back); -} - -/// Files written by psdtags (`scripts/layered_tiff_fixtures.py`): two RGB layers, "Background" -/// (RLE) and an offset, translucent "Upper é" (ZIP, Multiply, opacity 200), in both byte orders -/// at 8 and 16 bits. Binary files stay out of this repository: run the script to write them -/// to `corpus/layered-tiff/` (gitignored); without them these checks are skipped. -fn fixture(name: &str) -> Option> { - let bytes = std::fs::read(format!("{}/../../corpus/layered-tiff/{name}", env!("CARGO_MANIFEST_DIR"))).ok(); - if bytes.is_none() { - eprintln!("skipped {name}: run `python scripts/layered_tiff_fixtures.py` to write corpus/layered-tiff/"); - } - bytes -} - -#[test] -fn psdtags_fixtures_open_with_their_layers() { - for (name, depth) in [ - ("layered-le-8bit.tif", SampleType::U8), - ("layered-be-8bit.tif", SampleType::U8), - ("layered-le-16bit.tif", SampleType::U16), - ("layered-be-16bit.tif", SampleType::U16), - ] { - let Some(bytes) = fixture(name) else { continue }; - let r = import(name, &bytes).unwrap_or_else(|e| panic!("{name}: {e}")); - let d = &r.document; - assert_eq!(d.size, photocraft_geom::Size::new(6, 4), "{name}"); - assert_eq!(d.mode, ColorMode::Rgb); - assert_eq!(d.depth, depth, "{name}"); - let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, ["Background", "Upper \u{e9}"], "{name}"); - let upper = &d.layers[1]; - assert_eq!(upper.blend, photocraft_color::BlendMode::Multiply); - assert!((upper.opacity - 200.0 / 255.0).abs() < 1e-6); - let LayerContent::Raster(s) = &upper.content else { panic!("raster") }; - let bounds = s.content_bounds(); - assert_eq!((bounds.x0, bounds.y0, bounds.width(), bounds.height()), (1, 1, 4, 3), "{name}"); - // Pixel (x=2, y=0) of the upper layer: the generator's ramp, alpha 160/255. - let px = s.pixel(3, 1); - let expect = |v: u32| (v % 256) as f32 / 255.0; - assert!((px[0] - expect(2 * 40 + 7)).abs() < 1e-3, "{name}: {px:?}"); - assert!((px[1] - expect(7 * 3)).abs() < 1e-3, "{name}: {px:?}"); - assert!((px[3] - 160.0 / 255.0).abs() < 1e-3, "{name}: {px:?}"); - let LayerContent::Raster(bg) = &d.layers[0].content else { panic!("raster") }; - let b = bg.pixel(5, 3); - assert!((b[0] - expect(5 * 40 + 1)).abs() < 1e-3, "{name}: {b:?}"); - assert!((b[3] - 1.0).abs() < 1e-6); - assert_eq!(d.resolution_dpi, 300.0); - // tifffile writes a Software tag; nothing else is worth a warning. - assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings); - // psdtags wrote the composite's alpha sample without the transparency flag, so by the - // PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps - // everything else; the file is then in the encoder's byte order. - assert_eq!(d.channels.len(), 1, "{name}"); - let out = tiff(d, true); - let back = import(name, &out.bytes).unwrap().document; - assert_docs_eq(&expected(d), &back); - } -} - -/// Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`), fill opacity (`iOpa`), -/// channel restrictions (`brst`) and Blend If (the records' blending ranges) survive a layered -/// TIFF with its layer data in either byte order: the encoder's own, and the other one made by -/// transcoding the tag (the same length, spliced in place). -#[test] -fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { - use photocraft_doc::{AdvancedBlending, BlendRange, Knockout}; - for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { - let mut d = gen_doc(ColorMode::Rgb, depth, Features::PIXELS); - let adv = AdvancedBlending { - knockout: Knockout::Deep, - blend_interior: true, - blend_clipped: false, - transparency_shapes: false, - layer_mask_hides_effects: true, - vector_mask_hides_effects: true, - }; - d.layers[1].advanced = adv; - d.layers[1].fill_opacity = 64.0 / 255.0; - d.layers[1].excluded_channels = 0b010; - d.layers[1].blend_if.set(0, [BlendRange { black: [10, 40], white: [200, 250] }, BlendRange { black: [0, 0], white: [128, 128] }]); - d.layers[2].advanced.knockout = Knockout::Shallow; - let r = tiff(&d, true); - let (_, layers) = tags(&r.bytes); - let layers = layers.expect("layer data"); - let (isd, w) = ImageSourceData::from_bytes(&layers).expect("parse"); - assert!(w.is_empty(), "{w:?}"); - let native = isd.byte_order; - let other = if native == ByteOrder::Little { ByteOrder::Big } else { ByteOrder::Little }; - let (swapped, w) = isd.to_bytes(other).expect("transcode"); - assert!(w.is_empty(), "{depth:?}: blocks dropped while transcoding: {w:?}"); - assert_eq!(swapped.len(), layers.len()); - let at = r.bytes.windows(layers.len()).position(|x| x == &layers[..]).expect("tag data in the file"); - let mut flipped = r.bytes.clone(); - flipped[at..at + layers.len()].copy_from_slice(&swapped); - for (order, bytes) in [(native, &r.bytes), (other, &flipped)] { - let back = import("x.tif", bytes).unwrap_or_else(|e| panic!("{depth:?} {order:?}: {e}")).document; - let l = &back.layers[1]; - assert_eq!(l.advanced, adv, "{depth:?} {order:?}"); - assert_eq!(l.fill_opacity, 64.0 / 255.0, "{depth:?} {order:?}"); - assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}"); - assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}"); - assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}"); - assert_docs_eq(&expected(&d), &back); - } - } -} - -#[test] -fn damaged_layer_data_opens_flattened() { - let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); - let r = tiff(&d, true); - let (_, layers) = tags(&r.bytes); - let layers = layers.unwrap(); - let at = r.bytes.windows(layers.len()).position(|w| w == &layers[..]).unwrap(); - // Garbage after the signature: no layers, a warning, the composite as one layer. - let mut bad = r.bytes.clone(); - for b in &mut bad[at + 36..at + 36 + 24] { - *b ^= 0xa5; - } - let r2 = import("x.tif", &bad).unwrap(); - assert_eq!(r2.document.layers.len(), 1); - assert!(r2.warnings.iter().any(|w| w.contains("could not be read")), "{:?}", r2.warnings); - // Every truncation and a sweep of bit flips through the whole file: never a panic. - for cut in (0..r.bytes.len()).step_by(11) { - let _ = import("x.tif", &r.bytes[..cut]); - } - for i in (0..r.bytes.len()).step_by(29) { - let mut b = r.bytes.clone(); - b[i] ^= 0x5a; - let _ = import("x.tif", &b); - } - for name in ["layered-le-8bit.tif", "layered-be-16bit.tif"] { - let Some(bytes) = fixture(name) else { continue }; - for cut in (0..bytes.len()).step_by(7) { - let _ = import(name, &bytes[..cut]); - } - for i in (0..bytes.len()).step_by(5) { - let mut b = bytes.clone(); - b[i] ^= 0xff; - let _ = import(name, &b); - } - } -} - -#[test] -fn default_export_options_write_a_flat_tiff() { - // Scripted and agent saves (CLI, batch, MCP) build on the defaults: layers only on request. - let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); - assert!(tiff_layers::would_write_layers(&d)); - let r = export(&d, "x.tif", &ExportOptions::default()).unwrap(); - assert_eq!(tags(&r.bytes), (None, None)); - assert_eq!(import("x.tif", &r.bytes).unwrap().document.layers.len(), 1); -} - -/// #2940: layered TIFF data whose groups nest past the document's cap is rejected with the same -/// limit error as a PSD, instead of being converted. -#[test] -fn layered_tiff_nested_past_the_cap_is_rejected() { - use photocraft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder, PsdFile}; - let tiff_nested = |levels: usize| { - let mut b = PsdBuilder::new(4, 4); - for _ in 0..levels { - b.begin_group(GroupSpec::new("g")); - } - b.push_layer(LayerSpec::new("deep", 0, 0, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4]))); - for _ in 0..levels { - b.end_group().unwrap(); - } - let file = PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap(); - let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let layers = ImageSourceData::from_psd(&file).to_bytes(order).unwrap().0; - let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); - let mut img = photocraft_codecs::decode(&tiff(&d, false).bytes).unwrap(); - img.meta.photoshop_layers = Some(layers); - photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap() - }; - let err = import("deep.tif", &tiff_nested(101)).unwrap_err(); - assert!(err.to_string().contains("nested deeper than 100"), "{err}"); - let r = import("hundred.tif", &tiff_nested(100)).unwrap(); - assert_eq!(r.document.max_group_depth(), 100); -} diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs index 0f8f303ee0..7fc6532862 100644 --- a/crates/ipp/src/lib.rs +++ b/crates/ipp/src/lib.rs @@ -12,7 +12,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::io::{Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::path::PathBuf; use std::time::Duration; @@ -67,6 +67,8 @@ pub struct Printer { /// Timeouts: connecting, and each request (a Print-Job carries the whole PDF). const CONNECT: Duration = Duration::from_secs(3); const REQUEST: Duration = Duration::from_secs(60); +/// The whole reply, however it trickles in: the timeouts above only cover each read. +const REPLY_DEADLINE: Duration = Duration::from_secs(120); /// Largest reply read. const MAX_REPLY: usize = 16 << 20; /// Largest number of attributes taken from a reply. @@ -111,12 +113,36 @@ impl Endpoint { .find(Endpoint::reachable) } - /// `server` as a socket path (starts with `/`) or `host[:port]` (port 631 by default). + /// `server` as a socket path (starts with `/`) or `host[:port]` (port 631 by default). An IPv6 + /// server is `[address]` or `[address]:port`; written bare (more than one `:`) it keeps the + /// default port, because its last `:` cannot be told apart from a port separator. Anything else + /// after the bracket (`[::1]:abc`) stays the host, so a mistyped endpoint cannot resolve rather + /// than quietly becoming the default one. pub fn parse(server: &str) -> Endpoint { if server.starts_with('/') { return Endpoint::Unix(PathBuf::from(server)); } - let (host, port) = server.rsplit_once(':').and_then(|(h, p)| p.parse().ok().map(|p| (h.to_string(), p))).unwrap_or((server.to_string(), 631)); + // `[IPv6]:port` - the brackets tell the address from the port, and are not part of the host. + if let Some(rest) = server.strip_prefix('[') + && let Some((host, tail)) = rest.split_once(']') + { + // Nothing after the bracket, or a real `:port`, is understood. Anything else (`[::1]:abc`, + // `[::1]:70000`, `[::1]junk`) keeps the whole string as the host, so the mistyped endpoint + // stays unresolvable instead of quietly becoming the default one. + let port = if tail.is_empty() { + 631 + } else if let Some(port) = tail.strip_prefix(':').and_then(|p| p.parse().ok()) { + port + } else { + return Endpoint::Tcp(server.to_string(), 631); + }; + return Endpoint::Tcp(host.to_string(), port); + } + // A bare IPv6 literal has more than one ':', so splitting on the last one would cut the + // address in half: it is the host, and the port stays the default. A zone id (`fe80::1%eth0`) + // carries no ':', so there a port can still follow the address. + let host_port = if server.matches(':').count() > 1 && !server.contains('%') { None } else { server.rsplit_once(':') }; + let (host, port) = host_port.and_then(|(h, p)| p.parse().ok().map(|p| (h.to_string(), p))).unwrap_or((server.to_string(), 631)); Endpoint::Tcp(host, port) } @@ -142,17 +168,23 @@ impl Endpoint { Endpoint::Tcp(host, port) => { let addr = (host.as_str(), *port).to_socket_addrs().map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?.next(); let addr = addr.ok_or_else(|| Error::Unreachable(format!("{host}:{port}: no address")))?; - let s = TcpStream::connect_timeout(&addr, CONNECT).map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?; - let _ = s.set_read_timeout(Some(REQUEST)); - let _ = s.set_write_timeout(Some(REQUEST)); - Ok(Stream::Tcp(s)) + open_tcp(host, *port, addr, |addr| TcpStream::connect_timeout(&addr, CONNECT)) } } } + /// The endpoint as an HTTP authority: an IPv6 host is bracketed, or its last colon reads as the + /// port separator and the request carries a host CUPS has never heard of. A scoped address keeps + /// its zone id and writes it escaped as "%25": "%" is not valid inside an authority (RFC 3986 + /// section 3.2.2 reserves it for escaping), and the escaped form is what CUPS, curl and the + /// ipp:// URIs CUPS writes use for a zone. RFC 9844 (Aug 2025) obsoleted RFC 6874, which had made + /// that form URI syntax, so this is interop rather than a standard; dropping the zone instead + /// would name a different host on the wire than the socket was opened to. The socket still gets + /// the raw address%zone, which is the form a resolver understands. fn host(&self) -> String { match self { Endpoint::Unix(_) => "localhost".into(), + Endpoint::Tcp(host, port) if host.contains(':') => format!("[{}]:{port}", host.replace('%', "%25")), Endpoint::Tcp(host, port) => format!("{host}:{port}"), } } @@ -162,11 +194,22 @@ impl std::fmt::Display for Endpoint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Endpoint::Unix(p) => write!(f, "{}", p.display()), + Endpoint::Tcp(h, p) if h.contains(':') => write!(f, "[{h}]:{p}"), Endpoint::Tcp(h, p) => write!(f, "{h}:{p}"), } } } +/// Open one address and put the request timeouts on it. The connect call is a parameter so the +/// refusal path can be tested at all: no port is guaranteed to stay free between picking one and +/// using it, and what a refused connect reported is what the caller is supposed to hear. +fn open_tcp(host: &str, port: u16, addr: SocketAddr, open: impl FnOnce(SocketAddr) -> std::io::Result) -> Result { + let s = open(addr).map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?; + let _ = s.set_read_timeout(Some(REQUEST)); + let _ = s.set_write_timeout(Some(REQUEST)); + Ok(Stream::Tcp(s)) +} + enum Stream { #[cfg(unix)] Unix(std::os::unix::net::UnixStream), @@ -280,56 +323,31 @@ pub struct Reply { pub attributes: Vec, } +/// The attribute range for one group delimiter in a parsed message. Ranges are half-open and +/// include empty groups, so repeated group tags remain distinct. +#[derive(Debug, Clone, Copy)] +struct GroupRange { + tag: u8, + start: usize, + end: usize, +} + +/// Parsed message details kept private so the public flat `Reply` API stays source-compatible. +struct ParsedMessage { + reply: Reply, + end: usize, + groups: Vec, +} + impl Reply { /// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag. pub fn parse(b: &[u8]) -> Result { - Self::parse_at(b).map(|(r, _)| r) + parse_message(b).map(|parsed| parsed.reply) } /// Parses a message and returns where its attributes end (document data may follow). fn parse_at(b: &[u8]) -> Result<(Reply, usize)> { - let bad = |m: &str| Error::Protocol(m.to_string()); - if b.len() < 9 { - return Err(bad("reply shorter than an IPP header")); - } - let status = u16::from_be_bytes([b[2], b[3]]); - let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]); - let mut pos = 8; - let mut group = 0u8; - let mut attributes: Vec = Vec::new(); - loop { - let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?; - pos += 1; - if tag == TAG_END { - return Ok((Reply { status, request_id, attributes }, pos)); - } - if tag < 0x10 { - group = tag; - continue; - } - let name_len = - usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); - pos += 2; - let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?; - pos += name_len; - let value_len = - usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); - pos += 2; - let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec(); - pos += value_len; - if name_len == 0 { - // An additional value of the previous attribute. - match attributes.last_mut() { - Some(last) if last.group == group => last.values.push(value), - _ => return Err(bad("an additional value with no attribute before it")), - } - } else { - attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] }); - } - if attributes.len() > MAX_ATTRIBUTES { - return Err(bad("too many attributes")); - } - } + parse_message(b).map(|parsed| (parsed.reply, parsed.end)) } /// The server's own message for an error status, or a name for the code. @@ -351,8 +369,72 @@ impl Reply { } } -/// POSTs `body` (IPP, with any document data after it) to `path` and returns the reply. -fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result { +/// Parses one IPP message while preserving each group instance for private consumers that need +/// object boundaries. The public reply remains a flat list of attributes. +fn parse_message(b: &[u8]) -> Result { + let bad = |m: &str| Error::Protocol(m.to_string()); + if b.len() < 9 { + return Err(bad("reply shorter than an IPP header")); + } + let status = u16::from_be_bytes([b[2], b[3]]); + let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]); + let mut pos = 8; + let mut group = 0u8; + let mut active_group = None; + let mut last_attribute_group = None; + let mut attributes: Vec = Vec::new(); + let mut groups: Vec = Vec::new(); + loop { + let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?; + pos += 1; + if tag == TAG_END { + if let Some(last) = groups.last_mut() { + last.end = attributes.len(); + } + return Ok(ParsedMessage { reply: Reply { status, request_id, attributes }, end: pos, groups }); + } + if tag < 0x10 { + if let Some(last) = groups.last_mut() { + last.end = attributes.len(); + } + if groups.len() >= MAX_ATTRIBUTES { + return Err(bad("too many attribute groups")); + } + group = tag; + let group_index = groups.len(); + groups.push(GroupRange { tag, start: attributes.len(), end: attributes.len() }); + active_group = Some(group_index); + continue; + } + let name_len = + usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); + pos += 2; + let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?; + pos += name_len; + let value_len = + usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); + pos += 2; + let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec(); + pos += value_len; + if name_len == 0 { + // Additional values can continue an attribute only within this exact group instance. + match attributes.last_mut() { + Some(last) if last_attribute_group == active_group => last.values.push(value), + _ => return Err(bad("an additional value with no attribute before it")), + } + } else { + attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] }); + last_attribute_group = active_group; + } + if attributes.len() > MAX_ATTRIBUTES { + return Err(bad("too many attributes")); + } + } +} + +/// POSTs `body` (IPP, with any document data after it) to `path` and returns the parsed reply with +/// its private group boundaries. +fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result { let mut stream = endpoint.connect()?; let head = format!( "POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", @@ -374,9 +456,9 @@ fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result { })); } let body = if headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")) { dechunk(body)? } else { body.to_vec() }; - let reply = Reply::parse(&body)?; - if reply.status >= 0x0400 { - return Err(Error::Status(reply.status, reply.status_message())); + let reply = parse_message(&body)?; + if reply.reply.status >= 0x0400 { + return Err(Error::Status(reply.reply.status, reply.reply.status_message())); } Ok(reply) } @@ -393,7 +475,13 @@ fn read_response(stream: &mut Stream) -> Result> { let mut body_at = None; let mut expected: Option = None; 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; + let started = std::time::Instant::now(); loop { + if started.elapsed() > REPLY_DEADLINE { + return Err(Error::Protocol("the print server took too long to reply".into())); + } let n = stream.read(&mut buf).map_err(|e| Error::Protocol(format!("reading: {e}")))?; if n == 0 { return Ok(raw); @@ -414,7 +502,7 @@ fn read_response(stream: &mut Stream) -> Result> { let body = raw.get(at..).unwrap_or_default(); let complete = match expected { Some(len) if !chunked => body.len() >= len, - _ if chunked => chunked_complete(body), + _ if chunked => chunked_complete(body, &mut chunk_at), _ => false, }; if complete { @@ -442,8 +530,11 @@ fn http_response(raw: &[u8]) -> Result<(u16, Headers, &[u8])> { /// `0\r\n\r\n` inside a chunk's payload (an attribute value can hold it) does not count. A /// malformed or impossible size counts as complete, so [`dechunk`] reports it instead of /// waiting for more. -fn chunked_complete(mut b: &[u8]) -> bool { +fn chunked_complete(body: &[u8], resume: &mut usize) -> bool { + let mut b = body.get(*resume..).unwrap_or_default(); loop { + // Chunks already walked are not walked again: `resume` marks the start of this one. + *resume = body.len() - b.len(); let Some(line_end) = b.windows(2).position(|w| w == b"\r\n") else { return false }; let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[])); let Ok(size) = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16) else { return true }; @@ -511,41 +602,35 @@ pub fn printers(endpoint: &Endpoint) -> Result> { fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result> { let mut r = Request::new(op, 1); r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name", "printer-info", "printer-state", "printer-is-accepting-jobs"]); - let reply = match post(endpoint, "/", &r.end()) { + let parsed = match post(endpoint, "/", &r.end()) { Ok(reply) => reply, // CUPS answers "not found" ("No destinations added.") when no printer is set up. Err(Error::Status(0x0406, _)) => return Ok(Vec::new()), Err(e) => return Err(e), }; let mut out = Vec::new(); - let mut current: Option = None; - for a in reply.attributes.iter().filter(|a| a.group == TAG_PRINTER_GROUP) { - match a.name.as_str() { - "printer-name" => { - if let Some(p) = current.take() { - out.push(p); - } - current = Some(Printer { name: a.text(), info: String::new(), state: 3, accepting_jobs: true, is_default: false, is_class }); + let attributes = &parsed.reply.attributes; + for group in parsed.groups.iter().filter(|group| group.tag == TAG_PRINTER_GROUP) { + let Some(group_attributes) = attributes.get(group.start..group.end) else { + return Err(Error::Protocol("invalid printer attribute group range".into())); + }; + let mut name = None; + let mut info = String::new(); + let mut state = 3; + let mut accepting_jobs = true; + for a in group_attributes { + match a.name.as_str() { + "printer-name" if name.is_none() => name = Some(a.text()), + "printer-info" => info = a.text(), + "printer-state" => state = a.int().unwrap_or(3).clamp(0, 5) as u32, + "printer-is-accepting-jobs" => accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0)), + _ => {} } - "printer-info" => { - if let Some(p) = &mut current { - p.info = a.text(); - } - } - "printer-state" => { - if let Some(p) = &mut current { - p.state = a.int().unwrap_or(3).clamp(0, 5) as u32; - } - } - "printer-is-accepting-jobs" => { - if let Some(p) = &mut current { - p.accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0)); - } - } - _ => {} + } + if let Some(name) = name.filter(|name| !name.is_empty()) { + out.push(Printer { name, info, state, accepting_jobs, is_default: false, is_class }); } } - out.extend(current); Ok(out) } @@ -554,7 +639,7 @@ pub fn default_printer(endpoint: &Endpoint) -> Result> { let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2); r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]); match post(endpoint, "/", &r.end()) { - Ok(reply) => Ok(reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())), + Ok(reply) => Ok(reply.reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())), // CUPS answers "not found" when no default is set. Err(Error::Status(0x0406, _)) => Ok(None), Err(e) => Err(e), @@ -590,7 +675,7 @@ pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result { } let mut body = r.end(); body.extend_from_slice(job.document); - let reply = post(endpoint, &path, &body)?; + let reply = post(endpoint, &path, &body)?.reply; reply .attributes .iter() diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs index ab236d75ee..14ddf43846 100644 --- a/crates/ipp/src/tests.rs +++ b/crates/ipp/src/tests.rs @@ -137,6 +137,161 @@ fn printers_are_listed_with_state_and_default() { } } +#[test] +fn printer_attributes_before_name_are_preserved() { + let state = 5u32.to_be_bytes(); + let base: [Attr<'_>; 4] = [ + (TAG_TEXT, "printer-info", &[b"Studio Printer"]), + (TAG_ENUM, "printer-state", &[&state]), + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]), + (TAG_NAME, "printer-name", &[b"studio"]), + ]; + let body = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &base)]); + for chunked in [false, true] { + let (ep, _) = fake(body.clone(), chunked, 200); + let list = printers(&ep).unwrap(); + assert_eq!(list.len(), 1, "chunked {chunked}"); + assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state, list[0].accepting_jobs), ("studio", "Studio Printer", 5, false)); + assert!(!list[0].is_class); + } +} + +#[test] +fn printer_groups_do_not_share_metadata() { + let state_a = 4u32.to_be_bytes(); + let state_b = 5u32.to_be_bytes(); + let body = ipp( + 0, + &[ + ok_header(), + ( + TAG_PRINTER_GROUP, + &[(TAG_NAME, "printer-name", &[b"first"]), (TAG_TEXT, "printer-info", &[b"First printer"]), (TAG_ENUM, "printer-state", &[&state_a])], + ), + ( + TAG_PRINTER_GROUP, + &[(TAG_TEXT, "printer-info", &[b"Second printer"]), (TAG_ENUM, "printer-state", &[&state_b]), (TAG_NAME, "printer-name", &[b"second"])], + ), + ], + ); + let (ep, _) = fake(body, false, 200); + let list = queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap(); + assert_eq!(list.len(), 2); + assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state), ("first", "First printer", 4)); + assert_eq!((list[1].name.as_str(), list[1].info.as_str(), list[1].state), ("second", "Second printer", 5)); +} + +#[test] +fn printer_attribute_order_does_not_change_results() { + let state = 5u32.to_be_bytes(); + let fields: [Attr<'_>; 4] = [ + (TAG_TEXT, "printer-info", &[b"Studio Printer"]), + (TAG_ENUM, "printer-state", &[&state]), + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]), + (TAG_NAME, "printer-name", &[b"studio"]), + ]; + let expected = Printer { name: "studio".into(), info: "Studio Printer".into(), state: 5, accepting_jobs: false, is_default: false, is_class: false }; + let mut permutations = 0; + for first in 0..4 { + for second in 0..4 { + for third in 0..4 { + for fourth in 0..4 { + let order = [first, second, third, fourth]; + if order[0] == order[1] + || order[0] == order[2] + || order[0] == order[3] + || order[1] == order[2] + || order[1] == order[3] + || order[2] == order[3] + { + continue; + } + permutations += 1; + let ordered = order.iter().map(|index| fields[*index]).collect::>(); + let body = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &ordered)]); + let (ep, _) = fake(body, false, 200); + assert_eq!(queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap(), vec![expected.clone()], "order {order:?}"); + } + } + } + } + assert_eq!(permutations, 24); +} + +#[test] +fn class_attributes_before_name_are_preserved() { + let state = 5u32.to_be_bytes(); + let body = ipp( + 0, + &[ + ok_header(), + ( + TAG_PRINTER_GROUP, + &[ + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]), + (TAG_TEXT, "printer-info", &[b"Shared studio"]), + (TAG_ENUM, "printer-state", &[&state]), + (TAG_NAME, "printer-name", &[b"studio-class"]), + ], + ), + ], + ); + let (ep, rx) = fake(body, true, 200); + let classes = queues(&ep, OP_CUPS_GET_CLASSES, true).unwrap(); + assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_CLASSES); + assert_eq!(classes.len(), 1); + assert_eq!( + (classes[0].name.as_str(), classes[0].info.as_str(), classes[0].state, classes[0].accepting_jobs, classes[0].is_class), + ("studio-class", "Shared studio", 5, false, true) + ); +} + +#[test] +fn empty_or_unnamed_groups_do_not_leak_metadata() { + let body = ipp( + 0, + &[ + ok_header(), + (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"named"])]), + (TAG_PRINTER_GROUP, &[]), + (TAG_PRINTER_GROUP, &[(TAG_TEXT, "printer-info", &[b"orphan metadata"])]), + (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"next"]), (TAG_ENUM, "printer-state", &[&3u32.to_be_bytes()])]), + ], + ); + let (ep, _) = fake(body, false, 200); + let list = queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap(); + assert_eq!(list.len(), 2); + assert_eq!((list[0].name.as_str(), list[0].info.as_str()), ("named", "")); + assert_eq!((list[1].name.as_str(), list[1].info.as_str()), ("next", "")); +} + +#[test] +fn additional_values_do_not_cross_group_boundaries() { + let valid = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_KEYWORD, "supported", &[b"one", b"two"])])]); + let reply = Reply::parse(&valid).unwrap(); + assert_eq!(reply.attributes.iter().find(|a| a.name == "supported").map(|a| a.values.as_slice()), Some(&[b"one".to_vec(), b"two".to_vec()][..])); + + let mut body = vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_PRINTER_GROUP]; + body.extend_from_slice(&[TAG_NAME, 0, 12]); + body.extend_from_slice(b"printer-name"); + body.extend_from_slice(&[0, 5]); + body.extend_from_slice(b"first"); + body.extend_from_slice(&[TAG_TEXT, 0, 12]); + body.extend_from_slice(b"printer-info"); + body.extend_from_slice(&[0, 6]); + body.extend_from_slice(b"before"); + body.push(TAG_PRINTER_GROUP); + body.extend_from_slice(&[TAG_TEXT, 0, 0, 0, 5]); + body.extend_from_slice(b"after"); + body.extend_from_slice(&[TAG_NAME, 0, 12]); + body.extend_from_slice(b"printer-name"); + body.extend_from_slice(&[0, 6]); + body.extend_from_slice(b"second"); + body.push(TAG_END); + let (ep, _) = fake(body, true, 200); + assert!(matches!(queues(&ep, OP_CUPS_GET_PRINTERS, false), Err(Error::Protocol(_)))); +} + #[test] fn default_printer_is_read_and_absence_is_not_an_error() { let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]), false, 200); @@ -158,7 +313,10 @@ fn a_print_job_carries_the_document_and_returns_its_id() { assert_eq!(op, OP_PRINT_JOB); assert_eq!(doc, pdf); let get = |n: &str| attrs.iter().find(|a| a.name == n).cloned().unwrap_or_else(|| panic!("{n}")); - assert_eq!(get("printer-uri").values[0], format!("ipp://{}/printers/Office", ep.host()).as_bytes()); + let uri = String::from_utf8(get("printer-uri").values[0].clone()).unwrap(); + // Spelled out here rather than echoed back from host(), so the printer-uri the server receives is + // pinned down even when the authority is an IP literal. + assert!(uri.starts_with("ipp://127.0.0.1:") && uri.ends_with("/printers/Office"), "{uri}"); assert_eq!(get("job-name").values[0], b"photo.psd"); assert_eq!(get("requesting-user-name").values[0], b"diego"); assert_eq!(get("document-format").values[0], b"application/pdf"); @@ -186,9 +344,36 @@ fn error_statuses_and_broken_replies_are_errors() { assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); let (ep, _rx) = fake(b"not ipp at all".to_vec(), false, 200); assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); - // Nobody listening. - let e = printers(&Endpoint::Tcp("127.0.0.1".into(), 1)).unwrap_err(); + // Nobody reachable. A port is never guaranteed to be free, let alone to stay free: that is what + // made the old port-1 case fail on a FreeBSD CI VM. A name that can never resolve (RFC 2606) + // reaches the same exit without owning or releasing anything. + let e = printers(&Endpoint::Tcp("printer.invalid".into(), 631)).unwrap_err(); assert!(matches!(e, Error::Unreachable(_)), "{e}"); + assert!(e.to_string().contains("printer.invalid"), "{e} does not name the server it tried"); +} + +#[test] +fn a_refused_connection_is_unreachable_and_keeps_why() { + // The refused-connect path, with the machine no longer deciding what is closed: the connect call + // is a parameter, so this test cannot race for an ephemeral port with anything. + let addr: SocketAddr = "192.0.2.9:631".parse().unwrap(); + let asked_for = std::cell::RefCell::new(None); + let open = |seen: SocketAddr| -> std::io::Result { + *asked_for.borrow_mut() = Some(seen); + Err(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "nobody is listening on this port")) + }; + let e = match open_tcp("printserver", 631, addr, open) { + Ok(_) => panic!("nothing opens {addr}"), + Err(e) => e, + }; + assert_eq!(*asked_for.borrow(), Some(addr), "the connector is handed the resolved address"); + match &e { + Error::Unreachable(m) => { + assert!(m.contains("printserver:631"), "{e} does not name the endpoint"); + assert!(m.contains("nobody is listening on this port"), "{e} loses why the connect failed"); + } + other => panic!("expected Unreachable, got {other}"), + } } #[test] @@ -232,6 +417,54 @@ fn the_unix_socket_endpoint_works_and_detect_honours_cups_server() { let _ = std::fs::remove_dir_all(&dir); } +/// An IPv6 print server is not a `host:port` split: a bare address has more than one ':', and the +/// brackets of `[address]:port` must not end up inside the host, where no resolver can read them. +#[test] +fn an_ipv6_print_server_is_parsed_as_a_host_not_as_host_port() { + assert_eq!(Endpoint::parse("::1"), Endpoint::Tcp("::1".into(), 631)); + assert_eq!(Endpoint::parse("fe80::1"), Endpoint::Tcp("fe80::1".into(), 631)); + assert_eq!(Endpoint::parse("2001:db8::1"), Endpoint::Tcp("2001:db8::1".into(), 631)); + assert_eq!(Endpoint::parse("[::1]"), Endpoint::Tcp("::1".into(), 631)); + assert_eq!(Endpoint::parse("[::1]:631"), Endpoint::Tcp("::1".into(), 631)); + assert_eq!(Endpoint::parse("[fe80::1]:6310"), Endpoint::Tcp("fe80::1".into(), 6310)); + // A zone id carries no ':', so a port can still follow it without brackets. + assert_eq!(Endpoint::parse("fe80::1%eth0:631"), Endpoint::Tcp("fe80::1%eth0".into(), 631)); + // What already worked is unchanged. + assert_eq!(Endpoint::parse("cups.example"), Endpoint::Tcp("cups.example".into(), 631)); + assert_eq!(Endpoint::parse("cups.example:6310"), Endpoint::Tcp("cups.example".into(), 6310)); + assert_eq!(Endpoint::parse("127.0.0.1:6310"), Endpoint::Tcp("127.0.0.1".into(), 6310)); + assert_eq!(Endpoint::parse("/var/run/cups/cups.sock"), Endpoint::Unix("/var/run/cups/cups.sock".into())); + // A mistyped bracketed endpoint is not quietly the default one: the whole string stays the host, + // so nothing can resolve it and detect() reports the endpoint unreachable. + assert_eq!(Endpoint::parse("[::1]:abc"), Endpoint::Tcp("[::1]:abc".into(), 631)); + assert_eq!(Endpoint::parse("[::1]:70000"), Endpoint::Tcp("[::1]:70000".into(), 631)); + assert_eq!(Endpoint::parse("[::1]junk"), Endpoint::Tcp("[::1]junk".into(), 631)); + assert!(!Endpoint::parse("[::1]:abc").reachable()); +} + +/// The authority a request carries (the `Host:` header and the `ipp://` printer-uri) has to bracket an +/// IPv6 host, or its last colon reads as the port separator and CUPS sees a nonsense host. +#[test] +fn an_ipv6_endpoint_writes_its_authority_in_brackets() { + assert_eq!(Endpoint::parse("::1").host(), "[::1]:631"); + assert_eq!(Endpoint::parse("[::1]:631").host(), "[::1]:631"); + assert_eq!(Endpoint::parse("[fe80::1]:6310").host(), "[fe80::1]:6310"); + assert_eq!(Endpoint::parse("cups.example").host(), "cups.example:631"); + assert_eq!(Endpoint::parse("cups.example:6310").host(), "cups.example:6310"); + assert_eq!(Endpoint::parse("127.0.0.1:6310").host(), "127.0.0.1:6310"); + // A scoped address keeps its zone id, but the authority writes it "%25"-escaped: a bare "%" is + // not valid inside an authority, and the escaped form is what CUPS and curl-style ipp:// URIs + // use. The host itself stays raw, because that is what the resolver gets. + assert_eq!(Endpoint::parse("fe80::1%3:631").host(), "[fe80::1%253]:631"); + assert_eq!(Endpoint::parse("[fe80::1%3]:6310").host(), "[fe80::1%253]:6310"); + assert_eq!(Endpoint::parse("fe80::1%eth0:631").host(), "[fe80::1%25eth0]:631"); + assert_eq!(Endpoint::parse("fe80::1%3:631"), Endpoint::Tcp("fe80::1%3".into(), 631)); + // What the user is shown is written the same way, with the zone exactly as it was typed. + assert_eq!(Endpoint::parse("::1").to_string(), "[::1]:631"); + assert_eq!(Endpoint::parse("cups.example:6310").to_string(), "cups.example:6310"); + assert_eq!(Endpoint::parse("fe80::1%3:631").to_string(), "[fe80::1%3]:631"); +} + #[test] fn requests_start_with_the_mandatory_attributes() { let r = Request::new(OP_PRINT_JOB, 9).end(); @@ -351,10 +584,29 @@ fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() { assert_eq!(list.len(), 1); assert_eq!(list[0].name, "Of\r\n0\r\n\r\nfice"); // The boundary walk itself: complete only at a zero-size chunk, never on payload bytes. - assert!(!chunked_complete(b"5\r\nab\r\n0")); - assert!(!chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n")); - assert!(chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n")); - assert!(chunked_complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err()); + assert!(!complete(b"5\r\nab\r\n0")); + assert!(!complete(b"7\r\n\r\n0\r\n\r\nx\r\n")); + assert!(complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n")); + assert!(complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err()); +} + +/// [`chunked_complete`] on a whole body at once. +fn complete(body: &[u8]) -> bool { + chunked_complete(body, &mut 0) +} + +/// Fed a byte at a time, as a server dripping the reply would, the walk resumes where it stopped +/// and agrees with the one-shot answer at every length. +#[test] +fn the_chunk_walk_resumes_instead_of_starting_over() { + let body = b"3\r\nabc\r\n10\r\n0123456789abcdef\r\n0\r\n\r\n"; + let mut at = 0; + for len in 0..=body.len() { + let prefix = &body[..len]; + assert_eq!(chunked_complete(prefix, &mut at), complete(prefix), "{len}"); + assert!(at <= len); + } + assert!(at > 0, "it moved past the chunks already walked"); } /// A chunk size that cannot be satisfied (`usize::MAX`, `usize::MAX - 1`, anything past the @@ -362,7 +614,7 @@ fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() { #[test] fn an_impossible_chunk_size_is_a_protocol_error_not_a_panic() { for size_line in ["ffffffffffffffff", "fffffffffffffffe", "1000001"] { - assert!(chunked_complete(format!("{size_line}\r\n").as_bytes())); + assert!(complete(format!("{size_line}\r\n").as_bytes())); assert!(dechunk(format!("{size_line}\r\nab\r\n").as_bytes()).is_err()); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); diff --git a/crates/paint/Cargo.toml b/crates/paint/Cargo.toml index afb9a196d0..b72880c40c 100644 --- a/crates/paint/Cargo.toml +++ b/crates/paint/Cargo.toml @@ -13,5 +13,10 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } serde = { workspace = true } +# Large tips rasterize, accumulate and composite in parallel (the same pixels as serially). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + [dev-dependencies] +photocraft-cms = { workspace = true } serde_json = { workspace = true } diff --git a/crates/paint/examples/big_dab_bench.rs b/crates/paint/examples/big_dab_bench.rs new file mode 100644 index 0000000000..a31265224c --- /dev/null +++ b/crates/paint/examples/big_dab_bench.rs @@ -0,0 +1,44 @@ +//! 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]` +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_paint::{BrushContext, BrushSettings, Dab, StrokePoint, StrokeRenderer}; +use photocraft_raster::Surface; +use std::time::Instant; + +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 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]); + 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 rect = ctx.dab_rect(&dab, false); + let mut cov = Vec::new(); + let t = Instant::now(); + 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); + // 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)]); + 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(); + let dmg = r.composite(&pre, &mut target, None, false, false); + let comp = t.elapsed(); + 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)]); + 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/stroke_bench.rs b/crates/paint/examples/stroke_bench.rs new file mode 100644 index 0000000000..2a6b59cfa7 --- /dev/null +++ b/crates/paint/examples/stroke_bench.rs @@ -0,0 +1,64 @@ +//! A long stroke of one tip, timed per dab: a zigzag over a 4928×3280 RGBA8 document that sweeps +//! the whole page, `push` then `composite` after every point, as painting does per frame. Small +//! tips are the everyday case; a long stroke grows the stroke's tile map to thousands of tiles, +//! and the time per dab must not grow with it. +//! `cargo run --release -p photocraft-paint --example stroke_bench -- [size] [hardness] [dabs]` +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_paint::{BrushSettings, StrokePoint, StrokeRenderer}; +use photocraft_raster::Surface; +use std::time::Instant; + +fn main() { + let args: Vec = std::env::args().collect(); + let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20.0); + let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0); + let dabs: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(6000); + let (w, h) = (4928.0f64, 3280.0f64); + let brush = BrushSettings { size, hardness, 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, w as i32, h as i32), &[0.5, 0.5, 0.5, 1.0]); + let mut target = pre.clone(); + // Segments from the top margin to the bottom one and back, advancing across the width so that + // `dabs` dabs at the brush's spacing sweep the document once. + let step = f64::from(size) * 0.25; + let margin = f64::from(size); + let span = h - 2.0 * margin; + let segments = ((dabs as f64 * step) / span).ceil().max(1.0) as usize; + let dx = (w - 2.0 * margin) / segments as f64; + let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0); + let (mut t_push, mut t_comp) = (0.0f64, 0.0f64); + let t_all = Instant::now(); + let mut pos = (margin, margin); + r.push(&[StrokePoint::new(pos.0, pos.1, 1.0)]); + let mut pushes = 0usize; + 'stroke: for seg in 0..segments { + let (x0, y0) = pos; + let (x1, y1) = (margin + (seg + 1) as f64 * dx, if seg % 2 == 0 { h - margin } else { margin }); + let n = (((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt() / step).ceil().max(1.0) as usize; + for i in 1..=n { + let f = i as f64 / n as f64; + let t = Instant::now(); + r.push(&[StrokePoint::new(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f, 1.0)]); + let tp = t.elapsed().as_secs_f64(); + r.composite(&pre, &mut target, None, false, false); + t_comp += t.elapsed().as_secs_f64() - tp; + t_push += tp; + pushes += 1; + if r.dab_count() >= dabs { + break 'stroke; + } + } + pos = (x1, y1); + } + let total = t_all.elapsed().as_secs_f64(); + let placed = r.dab_count(); + println!( + "tip {size} px, hardness {hardness}: {placed} dabs in {pushes} pushes over {segments} segments: {:.0} ms (push {:.0}, composite {:.0}), {:.1} µs per dab", + total * 1e3, + t_push * 1e3, + t_comp * 1e3, + total * 1e6 / placed.max(1) as f64 + ); +} diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 7695ec3a60..fc61e41152 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -410,9 +410,9 @@ pub struct SectionLocks { pub protect_texture: bool, } -/// Brush Pose: stylus values used instead of (override) or for missing device data. +/// Brush Pose: fixed values used for the selected stylus-component overrides. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] +#[serde(from = "PoseWire", into = "PoseWire")] pub struct Pose { pub enabled: bool, /// Degrees, -90..90 (W3C Pointer Events convention). @@ -422,7 +422,8 @@ pub struct Pose { pub rotation: f32, /// 0..1. pub pressure: f32, - pub override_tilt: bool, + pub override_tilt_x: bool, + pub override_tilt_y: bool, pub override_rotation: bool, pub override_pressure: bool, } @@ -435,13 +436,88 @@ impl Default for Pose { tilt_y: 0.0, rotation: 0.0, pressure: 1.0, - override_tilt: false, + override_tilt_x: false, + override_tilt_y: false, override_rotation: false, override_pressure: false, } } } +// Old presets used one combined flag. Keep that JSON input and readback while the +// model has only the two independent sources of truth. Explicit axis fields win. +#[derive(Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct PoseWire { + enabled: bool, + tilt_x: f32, + tilt_y: f32, + rotation: f32, + pressure: f32, + override_tilt: bool, + #[serde(default, deserialize_with = "pose_axis_bool")] + override_tilt_x: Option, + #[serde(default, deserialize_with = "pose_axis_bool")] + override_tilt_y: Option, + override_rotation: bool, + override_pressure: bool, +} + +// Missing axis keys migrate from the old flag; a present null/non-boolean is an error. +fn pose_axis_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + bool::deserialize(d).map(Some) +} + +impl Default for PoseWire { + fn default() -> Self { + Self { + enabled: false, + tilt_x: 0.0, + tilt_y: 0.0, + rotation: 0.0, + pressure: 1.0, + override_tilt: false, + override_tilt_x: None, + override_tilt_y: None, + override_rotation: false, + override_pressure: false, + } + } +} + +impl From for Pose { + fn from(p: PoseWire) -> Self { + Self { + enabled: p.enabled, + tilt_x: p.tilt_x, + tilt_y: p.tilt_y, + rotation: p.rotation, + pressure: p.pressure, + override_tilt_x: p.override_tilt_x.unwrap_or(p.override_tilt), + override_tilt_y: p.override_tilt_y.unwrap_or(p.override_tilt), + override_rotation: p.override_rotation, + override_pressure: p.override_pressure, + } + } +} + +impl From for PoseWire { + fn from(p: Pose) -> Self { + Self { + enabled: p.enabled, + tilt_x: p.tilt_x, + tilt_y: p.tilt_y, + rotation: p.rotation, + pressure: p.pressure, + override_tilt: p.override_tilt_x && p.override_tilt_y, + override_tilt_x: Some(p.override_tilt_x), + override_tilt_y: Some(p.override_tilt_y), + override_rotation: p.override_rotation, + override_pressure: p.override_pressure, + } + } +} + /// Stroke smoothing (Photoshop's Smoothing % and its options). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 7bcf2d7a8f..d573e7cb3f 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -278,6 +278,58 @@ fn stroke_has_no_seams() { } } +#[test] +fn hard_round_presets_have_smooth_large_stroke_outlines() { + // #8842: the centre can be solid while widely spaced discs scallop the outline. + // Measure the rendered 50% edge of a 150 px stroke, including its sub-pixel position. + for preset in crate::presets::builtin().into_iter().filter(|p| p.name.starts_with("Hard Round")) { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for smoothing in [0.0, 0.49] { + let mut b = preset.brush.clone(); + b.size = 150.0; + b.smoothing.amount = smoothing; + let mut s = Surface::new(fmt); + render_stroke(&mut s, &b, &line(100.0, 800.0, 120.0), None, false, 1.0); + let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY); + for x in 220..680 { + let edge = (0..120) + .find_map(|y| { + let (a, next) = (s.rgba(x, y)[3], s.rgba(x, y + 1)[3]); + (a <= 0.5 && next > 0.5).then(|| y as f32 + (0.5 - a) / (next - a)) + }) + .expect("a full-pressure stroke has a half-coverage edge"); + min = min.min(edge); + max = max.max(edge); + } + assert!(max - min < 0.25, "{} {fmt:?} smoothing {smoothing}: scalloped edge spans {} px", preset.name, max - min); + } + } + } +} + +#[test] +fn hard_round_presets_have_continuous_pressure_opacity_tapers() { + // #8842 also shows nested disc boundaries when pressure controls opacity and size. + // A smooth input ramp must not turn into several-percent steps between adjacent pixels. + let points: Vec<_> = + (0..=100).map(|i| StrokePoint { time: i as f64 * 8.0, ..StrokePoint::new(100.0 + i as f64 * 7.0, 120.0, 0.95 - i as f32 * 0.009) }).collect(); + for preset in crate::presets::builtin().into_iter().filter(|p| p.name.starts_with("Hard Round")) { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for smoothing in [0.0, 0.49] { + let mut b = preset.brush.clone(); + b.size = 150.0; + b.pressure_opacity = true; + b.smoothing.amount = smoothing; + let mut s = Surface::new(fmt); + render_stroke(&mut s, &b, &points, None, false, 1.0); + assert!(s.rgba(200, 120)[3] > 0.7 && s.rgba(699, 120)[3] < 0.3, "{}: the stroke must still taper", preset.name); + let max_jump = (200..699).map(|x| (s.rgba(x + 1, 120)[3] - s.rgba(x, 120)[3]).abs()).fold(0.0f32, f32::max); + assert!(max_jump < 0.02, "{} {fmt:?} smoothing {smoothing}: opacity jumps by {max_jump}", preset.name); + } + } + } +} + // ---------- dynamics ---------- #[test] @@ -981,6 +1033,93 @@ fn catch_up_on_stroke_end_finishes_the_pose_of_a_still_pen() { assert_eq!(out.len(), n); } +#[test] +fn pose_axes_override_only_the_selected_stylus_components() { + let input = StrokePoint { x: 20.0, y: 30.0, pressure: 0.71, tilt_x: 13.0, tilt_y: -27.0, rotation: 67.0, wheel: 0.24, time: 73.0 }; + for enabled in [false, true] { + for (x, y) in [(false, false), (true, false), (false, true), (true, true)] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": enabled, "tiltX": 45, "tiltY": -18, + "overrideTiltX": x, "overrideTiltY": y, + "pressure": 0.4, "rotation": 120 + }})) + .unwrap(); + let mut expected = input; + if enabled && x { + expected.tilt_x = 45.0; + } + if enabled && y { + expected.tilt_y = -18.0; + } + assert_eq!(crate::dynamics::apply_pose(&b, input), expected, "enabled={enabled}, X={x}, Y={y}"); + } + } +} + +#[test] +fn pose_axes_reach_dabs_and_pixels_at_all_rgba_depths() { + let points = [ + StrokePoint { tilt_x: 13.0, tilt_y: -27.0, ..StrokePoint::new(20.0, 30.0, 0.71) }, + StrokePoint { tilt_x: -31.0, tilt_y: 42.0, ..StrokePoint::new(75.0, 45.0, 0.83) }, + ]; + for (x, y) in [(true, false), (false, true), (true, true)] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({ + "size": 20, "roundness": 0.47, "angle": -23, + "pressureSize": false, "smoothing": {"enabled": false}, + "shapeDynamics": {"enabled": true, "brushProjection": true}, + "pose": {"enabled": true, "tiltX": 45, "tiltY": -18, "overrideTiltX": x, "overrideTiltY": y} + })) + .unwrap(); + let expected_points: Vec<_> = + points.iter().map(|p| StrokePoint { tilt_x: if x { 45.0 } else { p.tilt_x }, tilt_y: if y { -18.0 } else { p.tilt_y }, ..*p }).collect(); + let mut expected_brush = b.clone(); + expected_brush.pose.enabled = false; + let actual_dabs = dabs_of(&b, &points); + let expected_dabs = dabs_of(&expected_brush, &expected_points); + assert_eq!(actual_dabs.len(), expected_dabs.len()); + for (index, (actual, expected)) in actual_dabs.iter().zip(&expected_dabs).enumerate() { + assert_eq!(actual, expected, "resolved tilt reaches dab {index}"); + } + for format in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut actual = Surface::new(format); + let mut expected = Surface::new(format); + render_stroke(&mut actual, &b, &points, None, false, 1.0); + render_stroke(&mut expected, &expected_brush, &expected_points, None, false, 1.0); + assert!(actual.rgba(20, 30)[3] > 0.0, "the comparison includes painted pixels"); + for py in 0..80 { + for px in 0..100 { + assert_eq!(actual.rgba(px, py), expected.rgba(px, py), "{format:?}, X={x}, Y={y}, pixel ({px},{py})"); + } + } + } + } +} + +#[test] +fn pose_axes_legacy_combined_override_and_existing_rotation_pressure_are_preserved() { + let input = StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, ..Default::default() }; + for enabled in [false, true] { + for legacy in [false, true] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": enabled, "tiltX": 45, "tiltY": -18, "overrideTilt": legacy, + "pressure": 0.4, "overridePressure": true, "rotation": 120, "overrideRotation": true + }})) + .unwrap(); + let expected = StrokePoint { + tilt_x: if enabled && legacy { 45.0 } else { input.tilt_x }, + tilt_y: if enabled && legacy { -18.0 } else { input.tilt_y }, + pressure: if enabled { 0.4 } else { input.pressure }, + rotation: if enabled { 120.0 } else { input.rotation }, + ..input + }; + assert_eq!(crate::dynamics::apply_pose(&b, input), expected); + let stored = serde_json::to_vec(&b).unwrap(); + let restored: BrushSettings = serde_json::from_slice(&stored).unwrap(); + assert_eq!(crate::dynamics::apply_pose(&restored, input), expected, "legacy brush settings retain their painting behavior through persistence"); + } + } +} + #[test] fn pose_overrides_stylus_values() { let b = BrushSettings { @@ -1601,6 +1740,106 @@ fn an_aliased_stroke_at_partial_opacity_is_one_flat_colour() { } } +/// Large tips rasterize, accumulate and composite in parallel (rows of a dab, tiles of a stroke); +/// the pixels are the ones the serial path gives, with 8-bit dithering, a selection, Dissolve and +/// per-dab colour included. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn large_tips_render_the_same_in_parallel_and_serially() { + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 900, 700), &[1.0]); + sel.fill_rect(Rect::new(300, 0, 400, 700), &[0.0]); + let strokes: Vec<(BrushSettings, Vec)> = vec![ + (BrushSettings { size: 700.0, hardness: 0.0, flow: 0.6, color: [0.9, 0.2, 0.1, 1.0], ..brush() }, line(200.0, 700.0, 350.0)), + ( + BrushSettings { size: 420.0, hardness: 1.0, opacity: 0.5, mode: BlendMode::Dissolve, color: [0.1, 0.5, 0.9, 1.0], ..brush() }, + line(150.0, 750.0, 300.0), + ), + ( + BrushSettings { + size: 500.0, + hardness: 0.3, + color_dynamics: ColorDynamics { enabled: true, hue_jitter: 0.5, per_tip: true, ..Default::default() }, + color: [0.2, 0.8, 0.3, 1.0], + ..brush() + }, + line(100.0, 800.0, 400.0), + ), + ]; + let render = |parallel_from: usize| -> Vec> { + crate::render::set_parallel_min_pixels(parallel_from); + strokes + .iter() + .map(|(b, pts)| { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 900, 700), &[0.5, 0.5, 0.5, 1.0]); + let (mut with_sel, mut plain) = (s.clone(), s); + render_stroke(&mut with_sel, b, pts, Some(&sel), false, 1.0); + render_stroke(&mut plain, b, pts, None, false, 1.0); + let mut px = with_sel.read_region(Rect::new(0, 0, 900, 700)); + px.extend(plain.read_region(Rect::new(0, 0, 900, 700))); + px + }) + .collect() + }; + // The threshold is process-wide: put the default back however the test ends, a panic + // inside a render included. + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS); + } + } + let _restore = Restore; + let parallel = render(0); + let serial = render(usize::MAX); + for (i, (p, s)) in parallel.iter().zip(&serial).enumerate() { + assert_eq!(p.len(), s.len()); + let diff = p.iter().zip(s).filter(|(a, b)| a != b).count(); + assert_eq!(diff, 0, "stroke {i}: {diff} pixels differ between the parallel and the serial render"); + assert!(p.iter().any(|v| (*v - 0.5).abs() > 0.1), "stroke {i} painted something"); + } +} + +/// A CMYK document with its own profile: blend modes other than Normal convert each pixel through +/// that profile, which is active per thread, so a stroke composited in parallel gives the pixels the +/// serial path gives (the workers enter the caller's CMYK space too). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_parallel_composite_converts_through_the_documents_cmyk_profile() { + use photocraft_cms::synth::{CmykParams, cmyk_profile}; + use photocraft_color::convert::{CmykSpace, with_cmyk_space}; + let profile = + cmyk_profile(&CmykParams { description: "Brush Test CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() }); + let bytes = profile.to_bytes(); + let Some(space) = CmykSpace::for_profile(Some(&bytes)) else { panic!("the synthetic profile is a CMYK space") }; + let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true); + // A 40 px Multiply line over 1100 px dirties more than 16 tiles: the composite's batch is + // worth the threads at the default threshold, and always at threshold 0. + let b = BrushSettings { size: 40.0, hardness: 0.5, mode: BlendMode::Multiply, color: [0.2, 0.4, 0.9, 1.0], ..brush() }; + let pts = line(40.0, 1140.0, 60.0); + let full = Rect::new(0, 0, 1200, 120); + let render = |parallel_from: usize| -> Vec { + crate::render::set_parallel_min_pixels(parallel_from); + let mut s = Surface::new(fmt); + s.fill_rect(full, &[0.1, 0.5, 0.3, 0.05, 1.0]); + with_cmyk_space(Some(&space), || render_stroke(&mut s, &b, &pts, None, false, 1.0)); + s.read_region(full) + }; + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS); + } + } + let _restore = Restore; + let parallel = render(0); + let serial = render(usize::MAX); + let diff = parallel.iter().zip(&serial).filter(|(a, b)| a != b).count(); + assert_eq!(diff, 0, "{diff} samples differ between the parallel and the serial composite in a profiled CMYK document"); + assert!(serial.chunks(5).any(|p| (p[1] - 0.5).abs() > 0.05), "the stroke painted something"); +} + /// Photoshop (measured): a 100 px brush with a 20 px Dual Brush tip resized to 50 / 2 / 1 px has a /// 10 / 0.4 / 0.2 px dual tip, unrounded and with no floor, and back at 100 px it is 20 px again; past /// 5000 px the dual tip is capped there and the proportion is lost (#4809). diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index a34effa6a5..5c8c88b640 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -26,6 +26,9 @@ const MAX_GRID_STEPS: f64 = 1e7; const MIN_SPEED_STEP: f64 = 0.5; /// Maximum airbrush catch-up dabs for one input segment. const MAX_AIRBRUSH_DABS_PER_SEGMENT: usize = 512; +/// Most steps one distance-spaced segment emits: two far-apart points at a quarter-pixel spacing +/// would otherwise be billions of steps, all buffered before the first is drawn. +const MAX_DISTANCE_STEPS_PER_SEGMENT: usize = 1 << 20; /// The dab centre of the pixel-grid cell holding `v` (see [`PathWalker::pixel_grid`]): the /// pixel's centre for `offset` 0.5, the nearest pixel corner for 0. A value within rounding @@ -336,12 +339,17 @@ impl PathWalker { match self.speed.filter(|_| dt > 0.0 && dt.is_finite()) { None => { if !self.grid.is_some_and(|g| self.grid_steps(&a, &p, g, out)) { - while self.next_at <= len + 1e-9 { + let mut emitted = 0usize; + while self.next_at <= len + 1e-9 && emitted < MAX_DISTANCE_STEPS_PER_SEGMENT { let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0)); self.emit(q, out); self.next_at += step_len(&q).max(0.25); + emitted += 1; } - self.next_at -= len; + // Cut short: the rest of the segment is skipped, spacing restarts at its end. + // Only when the cap held back a step that was still due; a segment that ends right at the cap keeps its carry-over. + let cut_short = emitted == MAX_DISTANCE_STEPS_PER_SEGMENT && self.next_at <= len + 1e-9; + self.next_at = if cut_short { step_len(&p).max(0.25) } else { self.next_at - len }; } } Some(iv) => { @@ -706,8 +714,10 @@ impl DualBuilder { pub fn apply_pose(b: &BrushSettings, mut p: StrokePoint) -> StrokePoint { let pose = &b.pose; if pose.enabled { - if pose.override_tilt { + if pose.override_tilt_x { p.tilt_x = pose.tilt_x; + } + if pose.override_tilt_y { p.tilt_y = pose.tilt_y; } if pose.override_rotation { @@ -813,3 +823,38 @@ impl DabGenerator { } } } + +#[cfg(test)] +mod walker_budget_tests { + use super::*; + + #[test] + fn one_far_segment_emits_a_bounded_number_of_steps() { + let steps = std::cell::Cell::new(0usize); + let mut count = |_: StepInput| steps.set(steps.get() + 1); + let mut walker = PathWalker::new(None); + walker.push(StrokePoint::new(0.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count); + // At a quarter pixel, a billion-pixel segment is four billion steps. + walker.push(StrokePoint::new(1.0e9, 0.0, 1.0), &|_: &StrokePoint| 0.25, &mut count); + assert!(steps.get() <= MAX_DISTANCE_STEPS_PER_SEGMENT + 1, "{}", steps.get()); + // Spacing carries on from the end of the cut segment. + let before = steps.get(); + walker.push(StrokePoint::new(1.0e9 + 10.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count); + assert!(steps.get() > before && steps.get() - before <= 11, "{} more steps", steps.get() - before); + } + + #[test] + fn a_segment_ending_at_the_cap_keeps_its_carry_over() { + let steps = std::cell::Cell::new(0usize); + let mut count = |_: StepInput| steps.set(steps.get() + 1); + let mut walker = PathWalker::new(None); + let one = |_: &StrokePoint| 1.0; + walker.push(StrokePoint::new(0.0, 0.0, 1.0), &one, &mut count); + // Exactly the cap in steps, then 0.75 left before the next is due. + let end = MAX_DISTANCE_STEPS_PER_SEGMENT as f64 + 0.25; + walker.push(StrokePoint::new(end, 0.0, 1.0), &one, &mut count); + let before = steps.get(); + walker.push(StrokePoint::new(end + 0.8, 0.0, 1.0), &one, &mut count); + assert_eq!(steps.get(), before + 1); + } +} diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index 49abb127a1..f620520b9b 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -6,7 +6,7 @@ //! weight. The reservoir amount drains per dab unless `load` is 100 %, so low Load strokes dry out. //! A dry brush (`wet = 0`) paints only reservoir colour; an empty, wet brush smears canvas colour. -use photocraft_raster::{Surface, from_rgba, to_rgba}; +use photocraft_raster::{Surface, from_rgba, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; @@ -27,6 +27,7 @@ pub struct MixerSettings { /// Pick up colour from all visible layers instead of the target layer only. pub sample_all_layers: bool, /// Load the brush with the stroke color after each stroke (the automatic Load option). + #[serde(alias = "reloadAfterStroke")] pub load_after_stroke: bool, /// Empty the brush after each stroke (the automatic Clean option). pub clean_after_stroke: bool, @@ -34,7 +35,7 @@ pub struct MixerSettings { impl Default for MixerSettings { fn default() -> Self { - Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true } + Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, clean_after_stroke: true, load_after_stroke: true } } } @@ -48,6 +49,8 @@ pub struct MixerState { pub amount: f32, /// Colour picked up from the canvas (straight RGBA), None = clean. pub pickup: 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 /// (the foreground). None = no stroke, load or clean yet. pub color: Option<[f32; 4]>, @@ -56,11 +59,13 @@ pub struct MixerState { impl MixerState { /// Load Brush: fill the reservoir with a color (full). pub fn load(&mut self, color: [f32; 4]) { + self.spatial = None; self.reservoir = Some(color); self.amount = 1.0; } /// Clean Brush: empty the reservoir and the pickup well. pub fn clean(&mut self) { + self.spatial = None; self.reservoir = None; self.amount = 0.0; self.pickup = None; @@ -76,11 +81,16 @@ impl MixerState { /// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load /// fills the reservoir again. 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 spatial = if load { self.spatial.take() } else { None }; + let paint = if spatial.is_some() { self.reservoir.unwrap_or(color) } else { color }; if clean { self.clean(); } if load { - self.load(color); + self.load(paint); + self.spatial = spatial; } self.color = Some(color); } @@ -91,6 +101,34 @@ impl MixerState { } } +/// Sample a spatial load at a dab pixel using the dab's rotation, aspect and flips. +/// Keeping the reservoir in tip coordinates lets it follow subsequent brush poses. +pub fn load_at(load: &[[f32; 4]], dab: &crate::Dab, x: f32, y: f32) -> [f32; 4] { + let mut dx = x - dab.center.x as f32; + let mut dy = y - dab.center.y as f32; + if dab.proj_scale < 0.999 { + let (sin, cos) = dab.proj_angle.sin_cos(); + let along = (dx * cos - dy * sin) * (1.0 / dab.proj_scale.max(0.05) - 1.0); + dx += along * cos; + dy -= along * sin; + } + let (sin, cos) = dab.angle.sin_cos(); + let mut u = (dx * cos - dy * sin) / dab.radius.max(0.01); + let mut v = (dx * sin + dy * cos) / (dab.radius * dab.roundness).max(0.5); + if dab.flip_x { + u = -u; + } + if dab.flip_y { + v = -v; + } + if !(-1.0..1.0).contains(&u) || !(-1.0..1.0).contains(&v) { + return [0.0; 4]; + } + let col = ((u + 1.0) * 32.0) as usize; + let row = ((v + 1.0) * 32.0) as usize; + load.get(row * 64 + col).copied().unwrap_or([0.0; 4]) +} + /// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself /// when None; a flattened composite for Sample All Layers). Returns the damaged rectangle. pub fn apply_mixer_stroke( @@ -103,6 +141,50 @@ pub fn apply_mixer_stroke( lock_transparency: bool, ) -> Rect { let fmt = target.format(); + let mut st = stroke.clone(); + st.brush.opacity = 1.0; + apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { + apply_mixer_dab(work, fp, fmt, sample, m, state, lock_transparency); + }) +} + +/// One incremental wet-paint dab, shared by recorded strokes and the live document transaction. +pub fn apply_mixer_dab( + work: &mut crate::retouch::Region, + fp: &crate::retouch::Footprint, + fmt: photocraft_color::PixelFormat, + sample: Option<&Surface>, + m: &MixerSettings, + state: &mut MixerState, + lock_transparency: bool, +) { + let sample = sample.map(|surface| { + let source = crate::retouch::Region::read(surface, work.rect); + if surface.format().mode == fmt.mode && surface.format().alpha == fmt.alpha { + source + } else { + let mut native = crate::retouch::Region::new(work.rect, fmt.channels()); + for (src, dst) in source.data.chunks_exact(source.ch).zip(native.data.chunks_exact_mut(native.ch)) { + from_rgba_into(&fmt, to_rgba(&surface.format(), src), dst); + } + native + } + }); + apply_mixer_dab_region(work, fp, fmt, sample.as_ref(), m, state, lock_transparency); +} + +/// Contiguous native-channel sample variant. `sample` must have the same rectangle and +/// channel layout as `work`. The live compositor supplies it directly, avoiding a tiled +/// temporary surface and a heap allocation for every sampled/deposited pixel. +pub fn apply_mixer_dab_region( + work: &mut crate::retouch::Region, + fp: &crate::retouch::Footprint, + fmt: photocraft_color::PixelFormat, + sample: Option<&crate::retouch::Region>, + m: &MixerSettings, + state: &mut MixerState, + lock_transparency: bool, +) { let n = fmt.channels(); let a = alpha_index(&fmt); let nc = fmt.mode.color_channels(); @@ -117,70 +199,81 @@ pub fn apply_mixer_stroke( 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 st = stroke.clone(); - st.brush.opacity = 1.0; let mut acc = vec![0.0f32; n]; let mut paint = vec![0.0f32; n]; - let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { - // 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)); - // 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); + // 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)); + // 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; + } + 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(), + }); + } + 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 }; + let total = wr + wp; + if total > 1e-6 { + for c in 0..n { + let rv = res.as_ref().map_or(0.0, |v| v[c]); + let pv = pickup.as_ref().map_or(0.0, |v| v[c]); + paint[c] = (rv * wr + pv * wp) / total; + } + let dep = total.min(1.0) * flow; + let mut local = vec![0.0; n]; + let mut mixed = vec![0.0; n]; 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: Vec = match sample { - Some(s) => { - let v = s.pixel(x, y); - if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } - } - None => work.px(x, y).to_vec(), - }; - 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(), - }); - } - 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 }; - let total = wr + wp; - if total > 1e-6 { - for c in 0..n { - let rv = res.as_ref().map_or(0.0, |v| v[c]); - let pv = pickup.as_ref().map_or(0.0, |v| v[c]); - paint[c] = (rv * wr + pv * wp) / total; - } - let dep = total.min(1.0) * flow; - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let k = fp.at(x, y) * dep; - if k > 0.0 { + let k = fp.at(x, y) * dep; + if k > 0.0 { + if let Some(load) = &state.spatial { + let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5); + from_rgba_into(&fmt, loaded, &mut local); + let local_wr = wr * loaded[3]; + let local_total = local_wr + wp; + if local_total > 1e-6 { + for (c, value) in mixed.iter_mut().enumerate() { + *value = (local.get(c).copied().unwrap_or(0.0) * local_wr + + pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp) + / local_total; + } + if let Some(ai) = a + && let Some(alpha) = mixed.get_mut(ai) + { + *alpha = 1.0; + } + over_native(&fmt, work.px_mut(x, y), &mixed, fp.at(x, y) * local_total.min(1.0) * flow, lock_transparency); + } + } else { over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency); } } } } - amount *= 1.0 - 0.08 * (1.0 - load); - }); + } + amount *= 1.0 - 0.08 * (1.0 - load); state.amount = amount; state.pickup = pickup.map(|p| to_rgba(&fmt, &p)); - dmg } #[cfg(test)] @@ -284,6 +377,38 @@ mod tests { assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None)); } + /// Spatial paint reservoirs honor independent automatic clean and reload choices. + #[test] + fn spatial_load_obeys_automatic_clean_and_load() { + let foreground = [0.0, 0.0, 1.0, 1.0]; + let sampled = [1.0, 0.0, 0.0, 1.0]; + let tip = vec![sampled; 4096]; + let mut state = MixerState::default(); + state.load(sampled); + state.spatial = Some(tip.clone()); + state.color = Some(foreground); + state.amount = 0.1; + state.end_stroke(foreground, true, true); + assert_eq!(state.spatial.as_ref(), Some(&tip)); + assert_eq!((state.reservoir, state.amount, state.pickup), (Some(sampled), 1.0, None)); + state.begin_stroke(foreground); + assert_eq!(state.spatial.as_ref(), Some(&tip)); + state.end_stroke(foreground, true, false); + assert!(state.spatial.is_none() && state.reservoir.is_none()); + state.begin_stroke(foreground); + assert!(state.reservoir.is_none(), "Clean stays empty until a new color or Load"); + } + + /// Older serialized reload settings remain compatible with the automatic load field. + #[test] + fn earlier_reload_setting_deserializes_as_automatic_load() { + let settings: MixerSettings = serde_json::from_value(serde_json::json!({"reloadAfterStroke":false})).unwrap(); + assert!(!settings.load_after_stroke); + let saved = serde_json::to_value(settings).unwrap(); + assert_eq!(saved["loadAfterStroke"], false); + assert!(saved.get("reloadAfterStroke").is_none()); + } + #[test] fn dry_empty_brush_paints_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); @@ -313,3 +438,7 @@ mod tests { assert!(run(1.0).is_some_and(|p| p[2] > 0.2)); } } + +#[cfg(test)] +#[path = "mixer_reference_tests.rs"] +mod reference_tests; diff --git a/crates/paint/src/mixer_reference_tests.rs b/crates/paint/src/mixer_reference_tests.rs new file mode 100644 index 0000000000..73242fa2f9 --- /dev/null +++ b/crates/paint/src/mixer_reference_tests.rs @@ -0,0 +1,154 @@ +// Frozen pre-optimization dab kernel. Keep independent of the optimized implementation. +use super::*; +/// Keep the allocation-heavy scalar kernel independent so optimizations cannot silently change pickup or blending. +fn reference( + work: &mut crate::retouch::Region, + fp: &crate::retouch::Footprint, + fmt: photocraft_color::PixelFormat, + sample: Option<&Surface>, + m: &MixerSettings, + state: &mut MixerState, + lock_transparency: bool, +) { + let n = fmt.channels(); + let a = alpha_index(&fmt); + let nc = fmt.mode.color_channels(); + let native = |c: [f32; 4]| { + let mut v = from_rgba(&fmt, c); + if let Some(ai) = a { + v[ai] = 1.0; + } + v + }; + 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 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)); + // 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; + } + let px: Vec = match sample { + Some(s) => { + let v = s.pixel(x, y); + if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } + } + None => work.px(x, y).to_vec(), + }; + 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(), + }); + } + 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 }; + let total = wr + wp; + if total > 1e-6 { + for c in 0..n { + let rv = res.as_ref().map_or(0.0, |v| v[c]); + let pv = pickup.as_ref().map_or(0.0, |v| v[c]); + paint[c] = (rv * wr + pv * wp) / total; + } + let dep = total.min(1.0) * flow; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let k = fp.at(x, y) * dep; + if k > 0.0 { + if let Some(load) = &state.spatial { + let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5); + let local = from_rgba(&fmt, loaded); + let local_wr = wr * loaded[3]; + let local_total = local_wr + wp; + if local_total > 1e-6 { + let mut mixed = paint.clone(); + for (c, value) in mixed.iter_mut().enumerate() { + *value = (local.get(c).copied().unwrap_or(0.0) * local_wr + + pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp) + / local_total; + } + if let Some(ai) = a + && let Some(alpha) = mixed.get_mut(ai) + { + *alpha = 1.0; + } + over_native(&fmt, work.px_mut(x, y), &mixed, fp.at(x, y) * local_total.min(1.0) * flow, lock_transparency); + } + } else { + over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency); + } + } + } + } + } + amount *= 1.0 - 0.08 * (1.0 - load); + state.amount = amount; + state.pickup = pickup.map(|p| to_rgba(&fmt, &p)); +} + +/// The incremental kernel matches the frozen reference across color models, bit depths and spatial loads. +#[test] +fn contiguous_mixer_matches_frozen_kernel_across_models_depths_and_spatial_loads() { + use crate::retouch::Region; + use crate::{BrushContext, BrushSettings, Stroke, StrokePoint}; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + let rect = Rect::new(-16, -16, 32, 32); + 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); + let values: Vec<_> = (0..rect.width() * rect.height()).flat_map(|i| from_rgba(&fmt, [(i % 31) as f32 / 30.0, 0.3, 0.7, 0.6])).collect(); + surface.write_region(rect, &values); + let mut source = Surface::new(PixelFormat::RGBA32F); + source.fill_rect(rect, &[0.8, 0.2, 0.1, 0.75]); + let brush = BrushSettings { size: 24.0, angle: 33.0, roundness: 0.6, pressure_size: false, ..Default::default() }; + let dabs = crate::dabs(&Stroke { brush: brush.clone(), points: vec![StrokePoint::new(4.0, 4.0, 1.0)] }); + let ctx = BrushContext::new(&brush); + for spatial in [false, true] { + for sample in [None, Some(&source), Some(&surface)] { + for lock in [false, true] { + let mut state = MixerState::default(); + state.load([0.1, 0.8, 0.2, 1.0]); + if spatial { + state.spatial = + Some((0..4096).map(|i| [if i % 64 < 32 { 1.0 } else { 0.0 }, 0.2, 0.8, if i % 7 == 0 { 0.0 } else { 1.0 }]).collect()); + } + let mut expected_state = state.clone(); + let mut actual = Region::read(&surface, rect); + let mut expected = actual.clone(); + let settings = MixerSettings::default(); + for (index, dab) in dabs.iter().enumerate() { + let fp = ctx.footprint(dab, index); + 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} lock={lock}"); + assert_eq!(state, expected_state); + } + } + } + } + } + } +} diff --git a/crates/paint/src/presets.rs b/crates/paint/src/presets.rs index f711d0eb51..dd87d2554e 100644 --- a/crates/paint/src/presets.rs +++ b/crates/paint/src/presets.rs @@ -36,19 +36,18 @@ fn paper(seed: u32, depth: f32) -> Texture { fn general(base: &BrushSettings) -> Vec { let g = |n: &str, b: BrushSettings| preset("General", n, b); + // Dense dabs keep large hard-round outlines and pressure-opacity tapers continuous (#8842). + let hard_round = BrushSettings { spacing: 0.05, ..base.clone() }; vec![ - g("Hard Round", BrushSettings { size: 30.0, hardness: 1.0, ..base.clone() }), + g("Hard Round", BrushSettings { size: 30.0, hardness: 1.0, ..hard_round.clone() }), g("Soft Round", BrushSettings { size: 45.0, hardness: 0.0, ..base.clone() }), - g("Hard Round Fine", BrushSettings { size: 5.0, hardness: 1.0, spacing: 0.1, ..base.clone() }), + g("Hard Round Fine", BrushSettings { size: 5.0, hardness: 1.0, ..hard_round.clone() }), g("Soft Round Large", BrushSettings { size: 300.0, hardness: 0.0, spacing: 0.15, ..base.clone() }), - g("Hard Round Pressure Size", BrushSettings { size: 30.0, hardness: 1.0, shape_dynamics: size_dyn(pressure()), ..base.clone() }), + g("Hard Round Pressure Size", BrushSettings { size: 30.0, hardness: 1.0, shape_dynamics: size_dyn(pressure()), ..hard_round.clone() }), g("Soft Round Pressure Size", BrushSettings { size: 45.0, hardness: 0.0, shape_dynamics: size_dyn(pressure()), ..base.clone() }), - g("Hard Round Pressure Opacity", BrushSettings { size: 30.0, hardness: 1.0, transfer: transfer(pressure(), Dynamic::default()), ..base.clone() }), + g("Hard Round Pressure Opacity", BrushSettings { size: 30.0, hardness: 1.0, transfer: transfer(pressure(), Dynamic::default()), ..hard_round.clone() }), g("Soft Round Pressure Opacity", BrushSettings { size: 45.0, hardness: 0.0, transfer: transfer(pressure(), Dynamic::default()), ..base.clone() }), - g( - "Hard Round Pressure Opacity and Flow", - BrushSettings { size: 30.0, hardness: 0.9, transfer: transfer(pressure(), pressure_min(0.2)), ..base.clone() }, - ), + g("Hard Round Pressure Opacity and Flow", BrushSettings { size: 30.0, hardness: 0.9, transfer: transfer(pressure(), pressure_min(0.2)), ..hard_round }), g("Airbrush Soft", BrushSettings { size: 80.0, hardness: 0.0, flow: 0.1, spacing: 0.1, build_up: true, build_up_rate: 25.0, ..base.clone() }), g( "Airbrush Pressure Flow", diff --git a/crates/paint/src/procedural.rs b/crates/paint/src/procedural.rs index 5a14521fba..5469dacebc 100644 --- a/crates/paint/src/procedural.rs +++ b/crates/paint/src/procedural.rs @@ -87,8 +87,20 @@ pub fn chalk_tip(n: u32, seed: u32) -> GrayTile { }) } +/// Most primitives one generated tip is drawn from, and most (pixel × primitive) visits it costs: +/// every pixel is tested against every primitive, so both counts are the caller's to multiply. +const MAX_PRIMITIVES: u32 = 4096; +const MAX_TIP_WORK: u64 = 1 << 27; + +/// `count` limited so that an `n`×`n` tip tests at most [`MAX_TIP_WORK`] pixel/primitive pairs. +fn clamp_primitives(n: u32, count: u32) -> u32 { + let per_primitive = (u64::from(n) * u64::from(n)).max(1); + count.min(MAX_PRIMITIVES).min(u32::try_from(MAX_TIP_WORK / per_primitive).unwrap_or(u32::MAX)) +} + /// A spatter tip: a cluster of round droplets of different sizes. pub fn spatter_tip(n: u32, seed: u32, drops: u32) -> GrayTile { + let drops = clamp_primitives(n, drops); let salt = u64::from(seed) + 202; let s = n as f32; let blobs: Vec<(f32, f32, f32)> = (0..drops) @@ -162,6 +174,7 @@ pub fn leaf_tip(n: u32) -> GrayTile { /// Grass: a tuft of tapered, slightly curved blades rising from the bottom edge. pub fn grass_tip(n: u32, seed: u32, blades: u32) -> GrayTile { + let blades = clamp_primitives(n, blades); let salt = u64::from(seed) + 505; let s = n as f32; let bl: Vec<(f32, f32, f32, f32)> = (0..blades) @@ -217,7 +230,7 @@ pub fn star_tip(n: u32) -> GrayTile { pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile { let salt = u64::from(seed) + 707; let s = n as f32; - let k = bristles.max(1); + let k = clamp_primitives(n, bristles).max(1); let dots: Vec<(f32, f32, f32)> = (0..k) .map(|i| { let x = s * (i as f32 + 0.5) / k as f32; @@ -239,6 +252,19 @@ pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile { mod tests { use super::*; + #[test] + fn tips_with_absurd_primitive_counts_stay_cheap() { + // 4 billion drops over a 1024² tip would be 4·10¹⁵ pixel tests. + let t = std::time::Instant::now(); + assert_eq!(spatter_tip(1024, 1, u32::MAX).width, 1024); + assert_eq!(grass_tip(1024, 1, u32::MAX).height, 1024); + assert_eq!(rake_tip(1024, 1, u32::MAX).width, 1024); + assert!(t.elapsed().as_secs() < 60, "{:?}", t.elapsed()); + assert!(clamp_primitives(1024, u32::MAX) <= MAX_PRIMITIVES); + assert!(clamp_primitives(60_000, 100) < 100, "a huge tip gets fewer primitives"); + assert_eq!(clamp_primitives(0, 7), 7); + } + #[test] fn patterns_are_seamless_and_in_range() { for st in [PatternStyle::Noise, PatternStyle::Paper, PatternStyle::Canvas, PatternStyle::Dots] { diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index b68e7a3190..b3246f1f57 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -39,6 +39,57 @@ fn dither8(px: &mut [f32], x: i32, y: i32) { /// Edge of a stroke-buffer tile. pub const COV_TILE: i32 = 64; + +/// Work on at least this many pixels runs in parallel (rows of a dab, tiles of a stroke): below +/// it the threads cost more than they save. A large soft tip is tens of megapixels per dab, and +/// every stage of it is independent per row or tile, so the pixels come out the same. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const DEFAULT_PARALLEL_MIN_PIXELS: usize = 256 * 256; + +/// The threshold in use: [`DEFAULT_PARALLEL_MIN_PIXELS`] (tests move it). +#[cfg(not(target_arch = "wasm32"))] +static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(DEFAULT_PARALLEL_MIN_PIXELS); + +/// Whether `pixels` of independent work are worth the thread pool (the web has none: there every +/// stage takes its serial path). +#[cfg(not(target_arch = "wasm32"))] +fn parallel(pixels: usize) -> bool { + pixels >= PARALLEL_MIN_PIXELS.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Tests: the pixel count from which work runs in parallel (`usize::MAX` keeps everything serial). +/// The static is process-wide, so brush tests on other threads see the change too; that is +/// harmless, because every stage gives the same pixels serially and in parallel (the point of +/// the test that uses this), so no test can tell which way it ran. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn set_parallel_min_pixels(n: usize) { + PARALLEL_MIN_PIXELS.store(n, std::sync::atomic::Ordering::Relaxed); +} + +/// `f` over `items`, in parallel when `pixels` are worth it. +fn for_each_item(items: &mut [T], pixels: usize, f: impl Fn(&mut T) + Sync) { + #[cfg(not(target_arch = "wasm32"))] + if parallel(pixels) { + use rayon::prelude::*; + items.par_iter_mut().for_each(&f); + return; + } + #[cfg(target_arch = "wasm32")] + let _ = pixels; + items.iter_mut().for_each(&f); +} + +/// `f` over `items`, collecting its results in order, in parallel when `pixels` are worth it. +fn map_items(items: &[T], pixels: usize, f: impl Fn(&T) -> R + Sync) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if parallel(pixels) { + use rayon::prelude::*; + return items.par_iter().map(&f).collect(); + } + #[cfg(target_arch = "wasm32")] + let _ = pixels; + items.iter().map(&f).collect() +} /// Computed tips with a smaller radius (in pixels) are rasterised by area, not at pixel centres. const SMALL_TIP_RADIUS: f32 = 3.0; /// Sub-pixel sample offsets (a 4×4 grid) for the area coverage of small tips. @@ -466,61 +517,62 @@ 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 tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&rect); if tr.is_empty() { continue; } - // Skip tiles this dab doesn't touch. - let any = (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) - }); - if !any { - continue; - } - let tile = self.tiles.entry((tx, ty)).or_insert_with(|| CovTile { - cov: vec![0.0; (COV_TILE * COV_TILE) as usize], - depth: Vec::new(), - col: if want_col { vec![0.0; (COV_TILE * COV_TILE) as usize * nc] } else { Vec::new() }, - }); + 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) + }) + }); + // 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; (COV_TILE * COV_TILE) as usize * nc]; + tile.col = vec![0.0; n * nc]; } if depth.is_some() && tile.depth.is_empty() { - tile.depth = vec![0.0; (COV_TILE * COV_TILE) as usize]; - } - self.dirty.insert((tx, ty)); - for y in tr.y0..tr.y1 { - let row = (y - rect.y0) as usize * w; - let trow = ((y - ty * COV_TILE) * COV_TILE) as usize; - for x in tr.x0..tr.x1 { - let v = vals[row + (x - rect.x0) as usize]; - if v <= 0.0 { - continue; - } - let ti = trow + (x - tx * COV_TILE) as usize; - if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) { - *slot = slot.max(*dv); - } - let c = tile.cov[ti]; - let nv = if ceil > c { c + v * (ceil - c) } else { c }; - if nv <= c { - continue; - } - if let (true, Some(col)) = (want_col, color) { - let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 }; - let p = &mut tile.col[ti * nc..(ti + 1) * nc]; - for i in 0..nc { - p[i] = col[i] * k + p[i] * (1.0 - k); - } - } - tile.cov[ti] = nv; - } + 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); } } + #[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); + } } fn take_dirty(&mut self) -> Vec<(i32, i32)> { @@ -587,6 +639,55 @@ impl UnionLive { } } +/// One dab's coverage (`vals` over `rect`, `w` wide) as [`CoverageMap::accumulate`] adds it to +/// a tile: the dab's opacity ceiling, its colour when the stroke has per-dab colour (`nc` channels), +/// its Wet Edges depth. +#[derive(Clone, Copy)] +struct DabValues<'a> { + vals: &'a [f32], + rect: Rect, + w: usize, + ceil: f32, + color: Option<&'a [f32]>, + depth: Option<&'a [f32]>, + nc: usize, +} + +impl DabValues<'_> { + /// 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) { + let DabValues { vals, rect, w, ceil, color, depth, nc } = self; + for y in tr.y0..tr.y1 { + let row = (y - rect.y0) as usize * w; + let trow = ((y - ty * COV_TILE) * COV_TILE) as usize; + for x in tr.x0..tr.x1 { + let v = vals[row + (x - rect.x0) as usize]; + if v <= 0.0 { + continue; + } + let ti = trow + (x - tx * COV_TILE) as usize; + if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) { + *slot = slot.max(*dv); + } + let c = tile.cov[ti]; + let nv = if ceil > c { c + v * (ceil - c) } else { c }; + if nv <= c { + continue; + } + if let Some(col) = color { + let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 }; + let p = &mut tile.col[ti * nc..(ti + 1) * nc]; + for i in 0..nc { + p[i] = col[i] * k + p[i] * (1.0 - k); + } + } + tile.cov[ti] = nv; + } + } + } +} + /// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels. #[derive(Clone, Debug)] pub struct StrokeRenderer { @@ -603,6 +704,19 @@ pub struct StrokeRenderer { dab_buf: Vec, dual_buf: Vec, all_dabs: Option>, + regions: RegionPool, +} + +/// The tile buffers [`StrokeRenderer::composite`] works in, kept from one composite to the next: +/// a small stroke's few tiles reuse theirs instead of growing and trimming the heap every frame +/// (that cost a 50 px stroke a third of its time). A clone starts with none. +#[derive(Debug, Default)] +struct RegionPool(Vec>); + +impl Clone for RegionPool { + fn clone(&self) -> Self { + Self::default() + } } impl StrokeRenderer { @@ -695,6 +809,7 @@ impl StrokeRenderer { dab_buf: Vec::new(), dual_buf: Vec::new(), all_dabs: None, + regions: RegionPool::default(), }) } Some(m) => { @@ -746,6 +861,7 @@ impl StrokeRenderer { dab_buf: Vec::new(), dual_buf: Vec::new(), all_dabs: None, + regions: RegionPool::default(), } } @@ -876,6 +992,7 @@ impl StrokeRenderer { dab_buf: dabs, dual_buf: duals, all_dabs: None, + regions: RegionPool::default(), }; t.raster_pending(); // Tiles whose coverage the tail doesn't change still composite the same: redraw them all. @@ -989,77 +1106,106 @@ impl StrokeRenderer { // colour, as in Photoshop, not grain that a tolerance-0 Magic Wand or Paint Bucket trips on. let dither = fmt.sample == SampleType::U8 && !b.aliased; let mut dmg = Rect::EMPTY; - let mut region = Vec::new(); - for (tx, ty) in keys { - let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { continue }; - let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds); - if tr.is_empty() { - continue; - } - pre.read_region_into(tr, &mut region); - let sel = selection.map(|s| (s.channels(), s.read_region(tr))); - let w = tr.width() as usize; - for y in tr.y0..tr.y1 { - for x in tr.x0..tr.x1 { - let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize; - let c = tile.cov[ti]; - if c <= 0.0 { - continue; - } - let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize; - let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y); - let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]); - let mut k = (m * opacity * s).min(1.0); - if k <= 0.0 { - continue; - } - if b.mode == BlendMode::Dissolve { - k = if hash2(x, y, b.seed) < k { 1.0 } else { 0.0 }; - if k == 0.0 { + // Each tile composites into a buffer of its own (a large stroke's tiles in parallel, to + // the same pixels); the writes to the target go one by one, in order. Batches keep the + // pending buffers few, and the pool keeps them from one composite to the next. + const BATCH: usize = 256; + let mut pool = std::mem::take(&mut self.regions.0); + // The document's CMYK profile is active per thread: the workers enter the caller's, or a + // blend mode other than Normal would convert their tiles through the built-in coated CMYK. + let cmyk = photocraft_color::convert::active_cmyk_space(); + for batch in keys.chunks(BATCH) { + 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 tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds); + if tr.is_empty() { + return; + } + pre.read_region_into(tr, region); + let sel = selection.map(|s| (s.channels(), s.read_region(tr))); + // Per-dab colour rewrites the source per pixel, so each tile works on its own copy + // that starts from the brush colour. (Before the tiles ran in parallel, one `src` + // carried over from tile to tile; a tile without per-dab colour then painted with + // whatever colour the previous tile's last pixel had left in it. Such a tile only + // occurs when the stroke has no per-dab colour at all, and then `src` is never + // rewritten, so the pixels are the same either way; the copy per tile is the + // intended reading.) + let mut src = src; + let w = tr.width() as usize; + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize; + let c = tile.cov[ti]; + if c <= 0.0 { continue; } - } - let px = &mut region[i * n..(i + 1) * n]; - if b.erase { - if let (Some(a), false) = (a_idx, lock_transparency) { - px[a] *= 1.0 - k; + let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize; + let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y); + let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]); + let mut k = (m * opacity * s).min(1.0); + if k <= 0.0 { + continue; + } + if b.mode == BlendMode::Dissolve { + k = if hash2(x, y, b.seed) < k { 1.0 } else { 0.0 }; + if k == 0.0 { + continue; + } + } + let px = &mut region[i * n..(i + 1) * n]; + if b.erase { + if let (Some(a), false) = (a_idx, lock_transparency) { + px[a] *= 1.0 - k; + } + if dither { + dither8(px, x, y); + } + continue; + } + if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) { + continue; + } + if self.per_dab_color && !tile.col.is_empty() { + let p = &tile.col[ti * nc..(ti + 1) * nc]; + for ch in 0..nc { + src[ch] = p[ch] / c; + } + } + if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) { + over_native(&fmt, px, &src[..n], k, lock_transparency); + } else { + let d = to_rgba(&fmt, px); + let sr = to_rgba(&fmt, &src[..n]); + let o = if lock_transparency { + photocraft_color::blend::composite_atop(b.mode, d, sr, k) + } else { + photocraft_color::blend::composite(b.mode, d, sr, k) + }; + let mut enc = [0.0f32; 8]; + from_rgba_into(&fmt, o, &mut enc); + px.copy_from_slice(&enc[..n]); } if dither { dither8(px, x, y); } - continue; - } - if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) { - continue; - } - if self.per_dab_color && !tile.col.is_empty() { - let p = &tile.col[ti * nc..(ti + 1) * nc]; - for ch in 0..nc { - src[ch] = p[ch] / c; - } - } - if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) { - over_native(&fmt, px, &src[..n], k, lock_transparency); - } else { - let d = to_rgba(&fmt, px); - let sr = to_rgba(&fmt, &src[..n]); - let o = if lock_transparency { - photocraft_color::blend::composite_atop(b.mode, d, sr, k) - } else { - photocraft_color::blend::composite(b.mode, d, sr, k) - }; - let mut enc = [0.0f32; 8]; - from_rgba_into(&fmt, o, &mut enc); - px.copy_from_slice(&enc[..n]); - } - if dither { - dither8(px, x, y); } } + *done = tr; + }; + for_each_item(&mut items, batch.len() * (COV_TILE * COV_TILE) as usize, |item| { + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || composite_tile(item)) + }); + for (_, tr, region) in items { + if !tr.is_empty() { + target.write_region(tr, ®ion); + dmg = dmg.union(&tr); + } + pool.push(region); } - target.write_region(tr, ®ion); - dmg = dmg.union(&tr); } + self.regions.0 = pool; if all { bounds } else { dmg } } } @@ -1090,22 +1236,57 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: let ro = d.roundness.max(0.5 / d.radius).min(1.0); let rm = (d.radius * ro).max(0.5); let w = rect.width() as usize; + if w == 0 { + return; + } let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) }; - let reach2 = (reach + 0.5) * (reach + 0.5); + 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 }; + // 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). + #[cfg(not(target_arch = "wasm32"))] + if parallel(out.len()) { + use rayon::prelude::*; + out.par_chunks_exact_mut(w).enumerate().for_each(|(yy, row)| tip.row(yy, row)); + return; + } for (yy, row) in out.chunks_exact_mut(w).enumerate() { - let dy = (rect.y0 + yy as i32) as f32 + 0.5 - cy; - if aliased { + tip.row(yy, row); + } +} + +/// A round tip's coverage, computed row by row (see [`rasterize_round`]). +#[derive(Clone, Copy)] +struct RoundTip { + cx: f32, + cy: f32, + ro: f32, + rm: f32, + reach2: f32, + hardness: f32, + alpha: f32, + aliased: bool, + x0: i32, + y0: i32, +} + +impl RoundTip { + /// Row `yy` of the tip's rectangle into `row`. + #[inline(always)] + fn row(self, yy: usize, row: &mut [f32]) { + let dy = (self.y0 + yy as i32) as f32 + 0.5 - self.cy; + if self.aliased { for (xx, dst) in row.iter_mut().enumerate() { - let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx; - let d2 = (dx * ro).powi(2) + dy * dy; - *dst = if d2 < reach2 && d2.sqrt() <= rm { d.alpha } else { 0.0 }; + let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx; + let d2 = (dx * self.ro).powi(2) + dy * dy; + *dst = if d2 < self.reach2 && d2.sqrt() <= self.rm { self.alpha } else { 0.0 }; } } else { for (xx, dst) in row.iter_mut().enumerate() { - let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx; - let d2 = (dx * ro).powi(2) + dy * dy; - let val = if d2 >= reach2 { 0.0 } else { tip_falloff(d2.sqrt(), rm, hardness) }; - *dst = if val <= 0.0 { 0.0 } else { val * d.alpha }; + 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) }; + *dst = if val <= 0.0 { 0.0 } else { val * self.alpha }; } } } diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 3cb02e9a24..20619cd48a 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -127,6 +127,8 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut return; } let (w, h) = (r.width() as usize, r.height() as usize); + let mut pre_px = vec![[0.0f32; 4]; w * h]; + pre.read_rgba_into(r, &mut pre_px); // Per-pixel match weight. let mut weight = vec![0.0f32; w * h]; for y in r.y0..r.y1 { @@ -136,7 +138,7 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut } // Match against the pre-stroke colours, so pixels recoloured by earlier dabs of // this stroke still count as the sampled colour. - let c = pre.rgba(x, y); + let c = pre_px[(y - r.y0) as usize * w + (x - r.x0) as usize]; if c[3] <= 0.0 { continue; } diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index dcb5a6b958..06bafe6cfb 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -284,6 +284,10 @@ pub fn mix_back(target: &mut Surface, orig: &Region, work: &Region, rect: Rect, let wv = work.px(x, y); let i = out.index(x, y) * n; let o = &mut out.data[i..i + n]; + // Untouched by the dabs: keep the pixel exactly (the premultiplied mix can round it). + if *o == *wv { + continue; + } match a { // Alpha changes: mix premultiplied, so colour under transparent pixels doesn't bleed in. Some(a) if !lock_transparency => mix_premultiplied(o, wv, a, k), @@ -336,8 +340,14 @@ pub struct Smudge { } impl Smudge { + /// Radius of the carried-colour buffer, including pixels outside a pressure-sized dab. + /// Both the first pickup and every subsequent pickup need this entire neighbourhood. + pub fn sampling_radius(max_brush_size: f32) -> i32 { + ((max_brush_size / 2.0).ceil() as i32).saturating_add(2) + } + pub fn new(strength: f32, finger: Option>, max_brush_size: f32) -> Self { - Self { strength: strength.clamp(0.0, 1.0), finger, radius: (max_brush_size / 2.0).ceil() as i32 + 2, alpha: None, carry: None } + Self { strength: strength.clamp(0.0, 1.0), finger, radius: Self::sampling_radius(max_brush_size), alpha: None, carry: None } } /// Mix in premultiplied space using alpha channel `a` (the format's [`alpha_index`]). diff --git a/crates/paint/src/tile.rs b/crates/paint/src/tile.rs index 38ca38410e..8ea0e776b6 100644 --- a/crates/paint/src/tile.rs +++ b/crates/paint/src/tile.rs @@ -40,7 +40,7 @@ impl GrayTile { self.data.iter().map(|&v| f32::from(v) / 65535.0).collect() } pub fn is_valid(&self) -> bool { - self.width > 0 && self.height > 0 && self.data.len() == self.width as usize * self.height as usize + self.width > 0 && self.height > 0 && (self.width as usize).checked_mul(self.height as usize) == Some(self.data.len()) } /// A box-filtered copy whose larger side is at most `max_side` (the whole tile when it is @@ -157,7 +157,9 @@ impl<'de> Deserialize<'de> for GrayTile { fn deserialize>(d: D) -> Result { use serde::de::Error; let t = GrayTileIn::deserialize(d)?; - let n = t.width as usize * t.height as usize; + let Some(n) = (t.width as usize).checked_mul(t.height as usize) else { + return Err(D::Error::custom("tile dimensions overflow the address space")); + }; let data: Vec = match t.data { DataIn::B64(s) => { let b = b64_decode(&s).ok_or_else(|| D::Error::custom("invalid base64 in tile data"))?; @@ -232,6 +234,9 @@ pub struct Mips { impl Mips { pub fn new(t: &GrayTile) -> Self { + if !t.is_valid() { + return Self { levels: vec![(1, 1, vec![0.0])] }; + } let mut levels = vec![(t.width as usize, t.height as usize, t.to_f32())]; while let Some((w, h, v)) = levels.last() { if *w <= 1 && *h <= 1 { @@ -335,6 +340,18 @@ mod tests { let f: GrayTile = serde_json::from_str(r#"{"width":2,"height":1,"data":[0.0,1.0]}"#).unwrap(); assert_eq!(f.data, vec![0, 65535].into()); assert!(serde_json::from_str::(r#"{"width":2,"height":2,"data":[0.0,1.0]}"#).is_err()); + assert!(serde_json::from_str::(r#"{"width":4294967295,"height":4294967295,"data":[0.0]}"#).is_err()); + let invalid = GrayTile { width: u32::MAX, height: u32::MAX, data: vec![1].into() }; + assert!(!invalid.is_valid()); + assert_eq!(Mips::new(&invalid).levels, vec![(1, 1, vec![0.0])]); + } + + /// `u32::MAX * u32::MAX` only overflows `usize` on 32-bit targets (wasm); 64-bit hits the length check. + #[cfg(target_pointer_width = "32")] + #[test] + fn oversized_dimensions_report_address_space_overflow() { + let err = serde_json::from_str::(r#"{"width":4294967295,"height":4294967295,"data":[0.0]}"#).unwrap_err(); + assert!(err.to_string().contains("tile dimensions overflow the address space")); } #[test] diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 09a69ca838..a88b7711f3 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -24,6 +24,10 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } +# Opening a plug-in file without blocking on a FIFO (the flag constant only). +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } + [dev-dependencies] # WebAssembly text -> binary for the hostile-module fixtures (Apache-2.0 WITH LLVM-exception). wat = "1.228" diff --git a/crates/plugins/src/apply.rs b/crates/plugins/src/apply.rs index 7a144a28b9..ba3f29f89d 100644 --- a/crates/plugins/src/apply.rs +++ b/crates/plugins/src/apply.rs @@ -70,6 +70,9 @@ fn read_clamped(s: &Surface, rect: Rect, extent: Rect) -> Vec { out } +/// Most bands one run is split into. +const MAX_BANDS: u64 = 1 << 16; + impl Plugin { /// The rectangle a run writes: the layer's content (grown by the plug-in's overlap) or the /// canvas, limited to the selection's bounds. @@ -113,6 +116,13 @@ impl Plugin { } let mut bands = Vec::new(); let (t0, t1) = (TileCoord::containing(area.x0, area.y0), TileCoord::containing(area.x1 - 1, area.y1 - 1)); + // Counted before any is made: a few distant tiles make `area` a grid of millions of + // bands, each a call into the plug-in, planned before the deadline starts. + let columns = (i64::from(t1.tx) - i64::from(t0.tx) + 1).max(0) as u64; + let rows = (i64::from(t1.ty) - i64::from(t0.ty) + 1).max(0) as u64; + if columns.div_ceil(across as u64).saturating_mul(rows) > MAX_BANDS { + return Err(Error::Limit(format!("more than {MAX_BANDS} bands (the layer's content is spread too widely)"))); + } for ty in t0.ty..=t1.ty { let mut tx = t0.tx; while tx <= t1.tx { diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 57ee000471..ce6e44ccd6 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -26,7 +26,7 @@ pub mod registry; mod runtime; pub use apply::format_code; -pub use manifest::{Area, Kind, Manifest, ParamSpec}; +pub use manifest::{Area, Kind, Manifest, ParamSpec, Unit}; pub use runtime::{ABI_VERSION, Limits, Plugin}; /// A plug-in error. Every way a module can misbehave ends here, never in a panic. diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 52994bc740..133c0fa843 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -30,12 +30,47 @@ pub enum Area { Canvas, } +/// What a `number` or `int` parameter measures (`"unit"` in the manifest). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Unit { + /// No unit (the default): a level, factor, count or percentage, the same at any image size. + #[default] + None, + /// A length in document pixels (a radius, distance, shift or size). On a reduced live preview + /// the host divides it by the reduction, so the preview matches what OK applies. + Px, +} + +impl Unit { + fn is_none(&self) -> bool { + *self == Unit::None + } +} + +/// Reads `"unit"` leniently: `"px"` is [`Unit::Px`]; anything else (an unknown string such as +/// `"pt"`, a number, `null`, an array…) is unitless, as when the field is absent. Manifests from +/// before `unit` existed could carry any value there (unknown fields were ignored), and they must +/// keep installing. +fn lenient_unit<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(match Value::deserialize(d)? { + Value::String(s) if s == "px" => Unit::Px, + _ => Unit::None, + }) +} + /// One declared parameter. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ParamSpec { /// A real number in `min..=max` (a slider in the dialog). - Number { min: f64, max: f64, default: f64 }, + Number { + min: f64, + max: f64, + default: f64, + #[serde(default, deserialize_with = "lenient_unit", skip_serializing_if = "Unit::is_none")] + unit: Unit, + }, /// A whole number in `min..=max`. Int { #[serde(default = "int_min")] @@ -44,6 +79,8 @@ pub enum ParamSpec { max: i64, #[serde(default)] default: i64, + #[serde(default, deserialize_with = "lenient_unit", skip_serializing_if = "Unit::is_none")] + unit: Unit, }, /// A checkbox. Bool { @@ -122,7 +159,7 @@ impl Manifest { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); } match spec { - ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => { + ParamSpec::Number { min, max, default, .. } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => { return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); } ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))), @@ -147,7 +184,7 @@ impl Manifest { .params .iter() .map(|(k, spec)| match spec { - ParamSpec::Number { min, max, default } => format!("\"{k}\":{min}..{max}={default}"), + ParamSpec::Number { min, max, default, .. } => format!("\"{k}\":{min}..{max}={default}"), ParamSpec::Int { default, .. } => format!("\"{k}\":int={default}"), ParamSpec::Bool { default } => format!("\"{k}\":bool={default}"), ParamSpec::Choice { options, default } => { @@ -179,11 +216,11 @@ impl Manifest { let v = given.get(k).filter(|v| !v.is_null()); let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}")); let resolved = match spec { - ParamSpec::Number { min, max, default } => match v { + ParamSpec::Number { min, max, default, .. } => match v { None => json!((*default).clamp(*min, *max)), Some(v) => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)), }, - ParamSpec::Int { min, max, default } => match v { + ParamSpec::Int { min, max, default, .. } => match v { None => json!((*default).clamp(*min, *max)), Some(v) => { let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?; @@ -209,6 +246,61 @@ impl Manifest { } Ok(out) } + + /// Whether parameter `key` is a length in pixels (`"unit": "px"`). + pub fn is_pixel_param(&self, key: &str) -> bool { + self.pixel_params().any(|(k, ..)| k == key) + } + + /// `(key, min, max, default, is_int)` of each parameter declared `"unit": "px"`. + fn pixel_params(&self) -> impl Iterator { + self.params.iter().filter_map(|(k, spec)| match spec { + ParamSpec::Number { min, max, default, unit: Unit::Px } => Some((k.as_str(), *min, *max, *default, false)), + ParamSpec::Int { min, max, default, unit: Unit::Px } => Some((k.as_str(), *min as f64, *max as f64, *default as f64, true)), + _ => None, + }) + } + + /// The value [`Manifest::resolve_params`] would use for a pixel parameter: the given number, or + /// the default when missing, within `min..=max`. `None` for a value of the wrong type (which + /// `resolve_params` rejects) or one that isn't finite. + fn pixel_value(params: &Map, key: &str, min: f64, max: f64, default: f64) -> Option { + let v = match params.get(key).filter(|v| !v.is_null()) { + None => default, + Some(v) => v.as_f64()?, + }; + // `max`/`min` rather than `clamp`, which panics on a reversed or NaN range. + v.is_finite().then(|| v.max(min).min(max)) + } + + /// Proxy factor for a live preview with `params` on a document reduced `k` times: the largest + /// factor up to `k` at which no pixel parameter with a positive minimum, divided by it, drops + /// below that minimum. A clamped value would change the feature size (as for built-in filters, + /// #2063), so such a preview runs less reduced instead. Always at least 1. + pub fn preview_factor(&self, params: &Map, k: u32) -> u32 { + self.pixel_params().fold(k.max(1), |k, (key, min, max, default, _)| match Self::pixel_value(params, key, min, max, default) { + Some(v) if min > 0.0 => k.min((v / min).floor().max(1.0).min(f64::from(k)) as u32), + Some(v) if max < 0.0 => k.min((v / max).floor().max(1.0).min(f64::from(k)) as u32), + _ => k, + }) + } + + /// Rewrites the plug-in's `params` for a live preview on a copy of the document reduced `k` + /// times: each pixel parameter (the given value, or its default when missing) is divided by + /// `k`, rounded for `int`s and kept within its range, so the preview shows the same lengths in + /// document pixels as OK applies (#7524). Unitless parameters, unknown keys and values of the + /// wrong type (which [`Manifest::resolve_params`] rejects) are left as they are. + pub fn scale_for_preview(&self, params: &mut Map, k: u32) { + if k <= 1 { + return; + } + let k = f64::from(k); + for (key, min, max, default, int) in self.pixel_params() { + let Some(v) = Self::pixel_value(params, key, min, max, default) else { continue }; + let x = (v / k).max(min).min(max); + params.insert(key.to_string(), if int { json!(x.round().max(min).min(max) as i64) } else { json!(x) }); + } + } } /// Reads the `params` object keeping its key order (serde_json's `Map` would sort it). @@ -224,10 +316,16 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result } fn visit_map>(self, mut map: A) -> std::result::Result { let mut out = Vec::new(); - while let Some((k, v)) = map.next_entry::()? { + while let Some(k) = map.next_key::()? { + // A repeated key would give one value two dialog controls, the last spec winning. + // Named before its spec is read or counted, so the error is about the key. + if out.iter().any(|(seen, _)| *seen == k) { + return Err(serde::de::Error::custom(format!("parameter {k:?} is declared twice"))); + } if out.len() >= MAX_PARAMS { return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})"))); } + let v = map.next_value::()?; out.push((k, v)); } Ok(out) @@ -306,8 +404,12 @@ fn visible(c: char) -> bool { !c.is_whitespace() && !c.is_control() && !in_ranges(c, FORMAT) && !in_ranges(c, BLANK) } +/// Names the host's `plugin.run` command takes for itself: a parameter of the same name would be +/// read as the command's routing (which layer or channel is filtered), not as the plug-in's. +const HOST_KEYS: [&str; 4] = ["id", "layer", "target", "channel"]; + fn valid_key(k: &str) -> bool { - (1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') && !k.starts_with('_') && k != "id" && k != "layer" + (1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') && !k.starts_with('_') && !HOST_KEYS.contains(&k) } #[cfg(test)] @@ -379,6 +481,9 @@ mod tests { r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#, r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#, r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#, + // Names the host command routes by: a plug-in default must not steer the filter. + r#"{"id":"a","name":"x","kind":"filter","params":{"target":{"type":"bool","default":false}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"channel":{"type":"int","min":0,"max":3,"default":1}}}"#, r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#, ] { assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}"); @@ -387,6 +492,128 @@ mod tests { assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err()); } + #[test] + fn rejects_a_parameter_declared_twice() { + // Both entries were kept: two dialog controls for one value, and the last spec won. + let twice = br#"{"id":"a","name":"x","kind":"filter", + "params":{"n":{"type":"bool"},"n":{"type":"number","min":0,"max":1,"default":0}}}"#; + assert!(matches!(Manifest::parse(twice), Err(Error::Manifest(_)))); + // The error names the key, also when the second spec is broken or past the limit. + let broken = br#"{"id":"a","name":"x","kind":"filter","params":{"n":{"type":"bool"},"n":{"type":"nope"}}}"#; + let many: Vec = (0..MAX_PARAMS).map(|i| format!(r#""p{i}":{{"type":"bool"}}"#)).collect(); + let past = format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{},"p0":{{"type":"bool"}}}}}}"#, many.join(",")); + for (json, key) in [(&broken[..], "n"), (past.as_bytes(), "p0")] { + let e = Manifest::parse(json).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains(&format!("parameter {key:?} is declared twice")), "{e}"); + } + // Keys differing in case are different parameters. + let m = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","params":{"n":{"type":"bool"},"N":{"type":"bool"}}}"#).unwrap(); + assert_eq!(m.params_notation(), r#"{"n":bool=false,"N":bool=false}"#); + } + + /// Chromatic Aberration-like: a pixel shift, a pixel int, a signed pixel offset and a unitless + /// `size` (a name the built-in filters treat as pixels). + const PIXELS: &str = r#"{"id":"org.example.fringe","name":"Fringe","kind":"filter", + "params":{"amount":{"type":"number","min":0,"max":100,"default":12,"unit":"px"}, + "radius":{"type":"int","min":2,"max":50,"default":10,"unit":"px"}, + "shift":{"type":"number","min":-40,"max":40,"default":0,"unit":"px"}, + "size":{"type":"number","min":0,"max":100,"default":50}, + "mix":{"type":"number","min":0,"max":1,"default":1,"unit":"none"}}}"#; + + #[test] + fn parses_units_and_keeps_old_manifests_unitless() { + let m = Manifest::parse(PIXELS.as_bytes()).unwrap(); + assert!(m.is_pixel_param("amount") && m.is_pixel_param("radius") && m.is_pixel_param("shift")); + assert!(!m.is_pixel_param("size") && !m.is_pixel_param("mix") && !m.is_pixel_param("missing")); + assert!(matches!(m.params[0].1, ParamSpec::Number { unit: Unit::Px, .. })); + assert!(matches!(m.params[1].1, ParamSpec::Int { unit: Unit::Px, .. })); + // No `unit` is unitless, and unitless params serialize as before (no `unit` key). + let old = Manifest::parse(FULL.as_bytes()).unwrap(); + assert!(old.params.iter().all(|(k, _)| !old.is_pixel_param(k))); + let schema = serde_json::to_value(&m).unwrap(); + assert_eq!(schema["params"]["amount"]["unit"], "px"); + assert!(schema["params"]["size"].get("unit").is_none() && schema["params"]["mix"].get("unit").is_none()); + assert_eq!(Manifest::parse(serde_json::to_string(&m).unwrap().as_bytes()).unwrap(), m); + // A unit is a `number`/`int` property; the dialog notation is unchanged by it. + assert_eq!(m.params_notation(), r#"{"amount":0..100=12,"radius":int=10,"shift":-40..40=0,"size":0..100=50,"mix":0..1=1}"#); + } + + #[test] + fn unknown_units_are_unitless() { + // Unknown fields were always ignored, so a manifest may already carry any `unit`: it must + // keep installing, unscaled, exactly as when the field is absent. + for unit in [r#""pt""#, r#""PX""#, r#""""#, "5", "true", "null", r#"["px"]"#, r#"{"px":1}"#] { + let text = format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{"r":{{"type":"number","min":0,"max":9,"default":1,"unit":{unit}}}}}}}"#); + for text in [text.clone(), text.replace(r#""type":"number""#, r#""type":"int""#)] { + let m = Manifest::parse(text.as_bytes()).unwrap_or_else(|e| panic!("{text}: {e}")); + assert!(!m.is_pixel_param("r"), "{text}"); + let mut p = json!({"r": 8}).as_object().unwrap().clone(); + m.scale_for_preview(&mut p, 4); + assert_eq!(Value::Object(p), json!({"r": 8}), "{text}"); + assert!(serde_json::to_value(&m).unwrap()["params"]["r"].get("unit").is_none(), "{text}"); + } + } + } + + #[test] + fn preview_scales_pixel_params_only() { + // #7524: at a reduction of 4, a 12 px shift must be 3 proxy pixels (12 document pixels). + let m = Manifest::parse(PIXELS.as_bytes()).unwrap(); + let given = json!({"id": "org.example.fringe", "amount": 12, "radius": 10, "shift": -20, "size": 40, "mix": 0.5}); + let mut p = given.as_object().unwrap().clone(); + m.scale_for_preview(&mut p, 4); + assert_eq!( + Value::Object(p.clone()), + json!({"id": "org.example.fringe", "amount": 3.0, "radius": 3, "shift": -5.0, "size": 40, "mix": 0.5}), + "px params divided (ints rounded, signs kept); unitless and other keys untouched" + ); + // The scaled values are what the plug-in receives. + assert_eq!(m.resolve_params(&Value::Object(p)).unwrap()["amount"], json!(3.0)); + // Missing px params get their scaled default, so the default isn't previewed unscaled. + let mut p = Map::new(); + m.scale_for_preview(&mut p, 2); + assert_eq!(Value::Object(p), json!({"amount": 6.0, "radius": 5, "shift": 0.0})); + // Never below the minimum, and values are clamped into range first, as `resolve_params` does. + let mut p = json!({"radius": 3, "amount": 1000}).as_object().unwrap().clone(); + m.scale_for_preview(&mut p, 4); + assert_eq!(Value::Object(p), json!({"radius": 2, "amount": 25.0, "shift": 0.0})); + // A full-resolution preview changes nothing. + for k in [0, 1] { + let mut p = given.as_object().unwrap().clone(); + m.scale_for_preview(&mut p, k); + assert_eq!(Value::Object(p), given); + } + // Wrong types and non-numbers are left for `resolve_params` to reject, never a panic. + let mut p = json!({"amount": "lots", "radius": true, "shift": [1]}).as_object().unwrap().clone(); + m.scale_for_preview(&mut p, 4); + assert_eq!(Value::Object(p.clone()), json!({"amount": "lots", "radius": true, "shift": [1]})); + assert!(m.resolve_params(&Value::Object(p)).is_err()); + // A plug-in without px params is never touched, even with built-in pixel names. + let old = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","params":{"radius":{"type":"number","min":0,"max":9,"default":4}}}"#).unwrap(); + let mut p = json!({"radius": 8}).as_object().unwrap().clone(); + old.scale_for_preview(&mut p, 4); + assert_eq!(Value::Object(p), json!({"radius": 8})); + } + + #[test] + fn preview_factor_keeps_pixel_params_above_their_minimum() { + let m = Manifest::parse(PIXELS.as_bytes()).unwrap(); + let p = |v: Value| v.as_object().unwrap().clone(); + // radius min 2: 10 px allows 4, 5 px allows 2, 3 px only full resolution. + assert_eq!(m.preview_factor(&p(json!({"radius": 10})), 4), 4); + assert_eq!(m.preview_factor(&p(json!({"radius": 5})), 4), 2); + assert_eq!(m.preview_factor(&p(json!({"radius": 3})), 4), 1); + // Missing: the default (10). Params with a zero or negative minimum don't limit it. + assert_eq!(m.preview_factor(&Map::new(), 8), 5); + assert_eq!(m.preview_factor(&p(json!({"radius": 50, "amount": 0, "shift": -40})), 8), 8); + // Never more than the document's factor, never 0; bad values don't limit it. + assert_eq!(m.preview_factor(&p(json!({"radius": 50})), 2), 2); + assert_eq!(m.preview_factor(&p(json!({"radius": 50})), 0), 1); + assert_eq!(m.preview_factor(&p(json!({"radius": "x"})), 4), 4); + let unitless = Manifest::parse(FULL.as_bytes()).unwrap(); + assert_eq!(unitless.preview_factor(&p(json!({"amount": 0.01})), 4), 4); + } + fn with_name(name: &str) -> Result { Manifest::parse(json!({"id": "org.example.tone", "name": name, "kind": "filter"}).to_string().as_bytes()) } diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 92eb5f3f1b..c819fb3de6 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -54,19 +54,35 @@ pub fn revision() -> u64 { REVISION.load(Ordering::Relaxed) } +/// Opens `path` for reading without waiting on a FIFO or device that has no writer. +#[cfg(unix)] +fn open_regular(path: &std::path::Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + // O_NONBLOCK: opening a writer-less FIFO read-only returns at once; the caller then sees it + // is not a regular file. Regular files ignore the flag. + std::fs::OpenOptions::new().read(true).custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32).open(path) +} + +#[cfg(not(unix))] +fn open_regular(path: &std::path::Path) -> std::io::Result { + std::fs::File::open(path) +} + /// Reads a `.wasm` file (refusing anything that isn't a regular file or exceeds the module limit) /// and loads it. #[cfg(not(target_arch = "wasm32"))] pub fn load_file(path: &std::path::Path, limits: Limits) -> Result { use std::io::Read; - let meta = std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + // Open first, without blocking on a FIFO, and judge the file that was opened: a name checked + // and then opened again can be swapped for a special file in between. + let f = open_regular(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + let meta = f.metadata().map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; if !meta.is_file() { return Err(Error::Io(format!("{} is not a file", path.display()))); } if meta.len() > limits.max_module_bytes as u64 { return Err(Error::Module(format!("{} is {} bytes (limit {})", path.display(), meta.len(), limits.max_module_bytes))); } - let f = std::fs::File::open(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; let mut bytes = Vec::new(); f.take(limits.max_module_bytes as u64 + 1).read_to_end(&mut bytes).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string())) diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index e436133b92..c82171a5ac 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -184,8 +184,9 @@ impl Plugin { let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); let mut store = Store::new(&self.engine, limits); store.limiter(|l| l); - // The start function (if any) runs on this fuel. - store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?; + // The start function (if any) runs on this fuel. It cannot be interrupted to check the + // deadline the way a call can, so it gets no more than one slice, however much a call may use. + store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?; let linker = Linker::::new(&self.engine); let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?; let memory = instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?; diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index a75eedd88b..61ee32791f 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -317,3 +317,48 @@ fn folder_loading_skips_bad_files() { assert!(photocraft_plugins::registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module"); std::fs::remove_dir_all(&dir).ok(); } + +#[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 + // clock, so it gets one slice of it at most. + let spin = "(func $spin (loop $l (br $l))) (start $spin)"; + let limits = Limits { fuel_base: u64::MAX / 4, ..Limits::default() }; + let (error, took) = run_err(&module(MANIFEST, 1, "(i32.const 0)", spin), limits); + assert!(matches!(error, Error::Trap(_) | Error::Limit(_)), "{error:?}"); + assert!(took < Duration::from_secs(30), "{took:?}"); +} + +#[test] +fn content_spread_over_millions_of_bands_is_refused_before_planning_them() { + let p = Plugin::load(INVERT, Limits::default()).unwrap(); + let mut s = Surface::new(PixelFormat::RGBA8); + s.write_pixel(0, 0, &[0.1, 0.2, 0.3, 1.0]); + s.write_pixel(4_000_000, 4_000_000, &[0.1, 0.2, 0.3, 1.0]); + let t = Instant::now(); + let e = p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({})).expect_err("a 15600 × 15600 tile grid is not one run"); + assert!(matches!(e, Error::Limit(_)), "{e:?}"); + assert!(t.elapsed() < Duration::from_secs(5), "{:?}", t.elapsed()); +} + +#[cfg(unix)] +#[test] +fn a_fifo_in_place_of_a_module_file_does_not_block_loading() { + let dir = std::env::temp_dir().join(format!("photocraft-plugin-fifo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("evil.wasm"); + let _ = std::fs::remove_file(&path); + // `rustix::fs::mknodat` is unavailable on Apple targets. `mkfifo` is the + // POSIX utility for creating this special file and keeps this regression + // test available on macOS as well as other Unix platforms. + let status = std::process::Command::new("mkfifo").arg(&path).status().unwrap(); + assert!(status.success(), "mkfifo failed with {status}"); + let (tx, rx) = std::sync::mpsc::channel(); + let p = path.clone(); + std::thread::spawn(move || { + let _ = tx.send(photocraft_plugins::registry::load_file(&p, Limits::default()).map(|_| ())); + }); + let r = rx.recv_timeout(Duration::from_secs(10)).expect("loading a FIFO must not block"); + assert!(matches!(r, Err(Error::Io(_))), "{r:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/psd/README.md b/crates/psd/README.md index d745fd5bd8..421d706ab7 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -106,6 +106,7 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla | PSB 8-byte-length keys | ✓ | ✓ | – | LMsk Lr16 Lr32 Layr Mt16 Mt32 Mtrn Alph FMsk lnk2 FEid FXid PxSD | | Lr16 / Lr32 / Layr layer info | ✓ | ✓ | ✓ | lifted into `layer_info`; re-inserted at its original position | | luni lsct lsdk lyid lnsr clbl infx knko lspf lclr iOpa shmd | ✓ | ✓ | ✓ | `TaggedBlock::parsed()` | +| Layer XMP (`shmd` › `cust` › `layerXMP`) | ✓ | ✓ | ✓ | `metadata::layer_xmp` / `take_layer_xmp` / `put_layer_xmp`, laid out as Photoshop 2026 writes it | | ActionDescriptor (all OSTypes) | ✓ | ✓ | ✓ | including references, `ObAr` (best effort), `UnFl`, `Pth `, version-16 wrapper | | Layer tree (groups) | ✓ | ✓ | ✓ | open/closed folders plus bounding dividers; tolerant of malformed nesting | | RGBA8 extraction | ✓ | – | – | RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone | @@ -144,7 +145,7 @@ allocations for padded channels return an error on failure. * Builder → `rgba8` correctness. * Unknown blend modes and unknown blocks pass through unchanged. -`tests/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently. +`tests/it/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently. A `cargo-fuzz` skeleton lives in `fuzz/`. It is its own workspace and is excluded from photocraft's. Run it with `cargo +nightly fuzz run parse` (or `descriptor`) from `crates/psd`. diff --git a/crates/psd/fuzz/Cargo.toml b/crates/psd/fuzz/Cargo.toml index 1c874bf4f0..8682bbb725 100644 --- a/crates/psd/fuzz/Cargo.toml +++ b/crates/psd/fuzz/Cargo.toml @@ -36,6 +36,13 @@ test = false doc = false bench = false +[[bin]] +name = "asl" +path = "fuzz_targets/asl.rs" +test = false +doc = false +bench = false + [[bin]] name = "image_source_data" path = "fuzz_targets/image_source_data.rs" diff --git a/crates/psd/fuzz/fuzz_targets/asl.rs b/crates/psd/fuzz/fuzz_targets/asl.rs new file mode 100644 index 0000000000..cbf2c9169c --- /dev/null +++ b/crates/psd/fuzz/fuzz_targets/asl.rs @@ -0,0 +1,15 @@ +//! Fuzz the `.asl` style library parser: any input must return `Ok` or `Err`, never panic, and +//! anything that parses must write back and parse to the same number of styles. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_psd::asl; + +fuzz_target!(|data: &[u8]| { + if let Ok(f) = asl::parse(data) + && let Ok(bytes) = asl::write(&f) + { + let back = asl::parse(&bytes).expect("a written library parses"); + assert_eq!(back.styles.len(), f.styles.len()); + } +}); diff --git a/crates/psd/fuzz/fuzz_targets/grd.rs b/crates/psd/fuzz/fuzz_targets/grd.rs index 2779a96846..16ab26a137 100644 --- a/crates/psd/fuzz/fuzz_targets/grd.rs +++ b/crates/psd/fuzz/fuzz_targets/grd.rs @@ -3,7 +3,7 @@ //! //! The cap assertions below are invariants, not fast checks: at libFuzzer default `-max_len` //! of 4096 no input can carry 10 001 gradients or 1001 stops, so they only fire on long runs with -//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/swatch_malformed.rs`. +//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/it/swatch_malformed.rs`. #![no_main] use libfuzzer_sys::fuzz_target; use photocraft_psd::grd::{self, GrdColor}; diff --git a/crates/psd/src/abr.rs b/crates/psd/src/abr.rs index 0a0a41ffbf..6a9f0806c5 100644 --- a/crates/psd/src/abr.rs +++ b/crates/psd/src/abr.rs @@ -435,6 +435,7 @@ fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion))); } let mut hierarchy = None; + let mut patterns = PatternBudget::default(); while r.left() >= 12 { let sig = r.arr::<4>()?; if &sig != b"8BIM" { @@ -458,16 +459,17 @@ fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result match &key { b"samp" => read_samp(data, out.subversion, out, budget)?, b"patt" => { - // Patterns share the samples' budget, so neither can claim it twice. + // Patterns share the samples' budget and one padding budget across every `patt` + // section, so neither can be claimed twice and failed blocks still deduct their + // decoded charges. let limit = u64::try_from(budget.0).unwrap_or(u64::MAX); - let mut patterns = PatternBudget::with_decoded_limit(limit); + patterns.cap_decoded_to(limit); let left = patterns.decoded_left(); - match parse_pattern_block_with(data, &mut patterns) { - Ok(p) => { - let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX); - budget.spend(spent)?; - out.patterns.extend(p); - } + let res = parse_pattern_block_with(data, &mut patterns); + let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX); + budget.spend(spent)?; + match res { + Ok(p) => out.patterns.extend(p), Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")), } } @@ -706,10 +708,39 @@ mod tests { assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 2, 0)); // One byte short: the patterns no longer fit after the sample (they used not to count). let (out, left) = parse_within(189); - assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 89)); + assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 14)); assert!(out.warnings.iter().any(|w| w.contains("embedded patterns unreadable")), "{:?}", out.warnings); } + #[test] + fn failed_pattern_section_does_not_refund_budget_to_later_sections() { + let p = PsdPattern { + mode: 3, + width: 5, + height: 3, + name: "p".into(), + id: "id".into(), + palette: None, + depth: 8, + channels: vec![vec![1; 15], vec![2; 15], vec![3; 15]], + alpha: None, + }; + let valid = write_pattern_block(std::slice::from_ref(&p)).unwrap(); + let mut malformed = valid.clone(); + malformed.extend_from_slice(&100u32.to_be_bytes()); + + let mut body = 2u16.to_be_bytes().to_vec(); + section(&mut body, b"patt", &malformed); + section(&mut body, b"patt", &valid); + let mut out = AbrFile::default(); + let mut budget = Budget(60); + parse_v6_within(&mut Rd::new(&body), &mut out, &mut budget).unwrap(); + + assert!(out.patterns.is_empty(), "a failed block is discarded atomically"); + assert_eq!(budget.0, 0, "the failed block's first 45 bytes and later partial 15-byte charge are retained"); + assert_eq!(out.warnings.iter().filter(|w| w.contains("embedded patterns unreadable")).count(), 2); + } + #[test] fn v1_v2_round_trip_raw_and_rle() { for version in [1u16, 2] { diff --git a/crates/psd/src/asl.rs b/crates/psd/src/asl.rs new file mode 100644 index 0000000000..26cd27b9bc --- /dev/null +++ b/crates/psd/src/asl.rs @@ -0,0 +1,276 @@ +//! Photoshop layer style libraries (`.asl`, the Styles panel's Import Styles… / Export Styles…). +//! +//! Layout of a version 3 style library: +//! +//! ```text +//! file := u16 2, "8BSL", u16 3 (sub-version), +//! u32 pattern section length, patterns (the `Patt` block record format), +//! u32 style count, style × count +//! style := u32 length, then that many bytes: identity, style descriptor, zero padding +//! identity:= versioned descriptor (class `null`): `Nm ` name, `Idnt` id (Unicode strings) +//! style descriptor := versioned descriptor (class `Styl`): `documentMode`, `Lefx` (the same +//! tree as a layer's `lfx2` block), optional `blendOptions` (`Md `, `Opct`, +//! `fillOpacity`) +//! ``` +//! +//! The reader skips up to three padding bytes after each style record whatever its length says, +//! so it reads files whose record length counts the padding and files whose length does not. +//! The writer counts the padding in the record length, which reads correctly either way. +//! +//! Malformed input is an error, never a panic. A pattern section that doesn't decode is reported +//! in [`AslFile::pattern_error`] and skipped (its length is known), so the styles still load. + +use crate::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; +use crate::error::{PsdError, Result}; +use crate::patterns::{PsdPattern, parse_pattern_block, write_pattern_block}; + +/// Most styles read from one file. +pub const MAX_STYLES: usize = 10_000; + +/// One style of a library. +#[derive(Debug, Clone, PartialEq)] +pub struct AslStyle { + /// The name shown in the Styles panel (may be empty or repeated). + pub name: String, + /// The style's unique id (usually a UUID). + pub id: String, + /// The style descriptor (class `Styl`): `Lefx`, `blendOptions`, `documentMode`. + pub style: Descriptor, +} + +/// A parsed style library. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AslFile { + /// Patterns embedded for the styles' Pattern Overlays, pattern strokes and bevel textures. + pub patterns: Vec, + /// Why the pattern section was skipped, if it didn't decode (read only). + pub pattern_error: Option, + /// Styles in file order. + pub styles: Vec, +} + +struct Rd<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Rd<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self.p.checked_add(n).filter(|&e| e <= self.b.len()).ok_or(PsdError::UnexpectedEof { offset: self.p, needed: n })?; + let s = self.b.get(self.p..end).ok_or(PsdError::UnexpectedEof { offset: self.p, needed: n })?; + self.p = end; + Ok(s) + } + fn u16(&mut self) -> Result { + let b = self.take(2)?; + Ok(u16::from_be_bytes([b[0], b[1]])) + } + fn u32(&mut self) -> Result { + let b = self.take(4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + fn remaining(&self) -> usize { + self.b.len().saturating_sub(self.p) + } +} + +fn text(d: &Descriptor, key: &str) -> String { + match d.get(key) { + Some(Value::Text(t)) => t.to_string_lossy(), + _ => String::new(), + } +} + +fn read_style(body: &[u8]) -> Result { + let (identity, used) = VersionedDescriptor::parse_prefix(body)?; + let rest = body.get(used..).ok_or(PsdError::UnexpectedEof { offset: used, needed: 4 })?; + let (style, _) = VersionedDescriptor::parse_prefix(rest)?; + let id = &identity.descriptor; + Ok(AslStyle { name: text(id, "Nm "), id: text(id, "Idnt"), style: style.descriptor }) +} + +/// Parses an `.asl` style library. +pub fn parse(data: &[u8]) -> Result { + let mut r = Rd { b: data, p: 0 }; + let version = r.u16()?; + let sig = r.take(4)?; + if sig != b"8BSL" { + return Err(PsdError::InvalidSignature { expected: "8BSL", found: [sig[0], sig[1], sig[2], sig[3]] }); + } + let sub = r.u16()?; + if version != 2 || sub != 3 { + return Err(PsdError::Unsupported(format!("style library version {version}.{sub} (only 2.3, Photoshop 6 and later)"))); + } + let plen = r.u32()? as usize; + let section = r.take(plen)?; + let (patterns, pattern_error) = match parse_pattern_block(section) { + Ok(p) => (p, None), + Err(e) => (Vec::new(), Some(e.to_string())), + }; + let count = r.u32()? as usize; + // Every style needs at least its own length word. + if count > r.remaining() / 4 { + return Err(PsdError::invalid(format!("{count} styles cannot fit in the remaining {} bytes", r.remaining()))); + } + if count > MAX_STYLES { + return Err(PsdError::LimitExceeded("too many styles in one library")); + } + let mut styles = Vec::with_capacity(count); + for _ in 0..count { + let len = r.u32()? as usize; + let body = r.take(len)?; + styles.push(read_style(body)?); + let pad = (4 - len % 4) % 4; + r.p = r.p.saturating_add(pad).min(data.len()); + } + Ok(AslFile { patterns, pattern_error: pattern_error.filter(|_| !section.is_empty()), styles }) +} + +/// Writes a version 3 `.asl` style library. +pub fn write(file: &AslFile) -> Result> { + let mut out = Vec::new(); + out.extend_from_slice(&2u16.to_be_bytes()); + out.extend_from_slice(b"8BSL"); + out.extend_from_slice(&3u16.to_be_bytes()); + let patterns = write_pattern_block(&file.patterns)?; + let plen = u32::try_from(patterns.len()).map_err(|_| PsdError::LimitExceeded("pattern section larger than 4 GB"))?; + out.extend_from_slice(&plen.to_be_bytes()); + out.extend_from_slice(&patterns); + let count = u32::try_from(file.styles.len()).map_err(|_| PsdError::LimitExceeded("too many styles"))?; + out.extend_from_slice(&count.to_be_bytes()); + for s in &file.styles { + let identity = + Descriptor::new("null").with("Nm ", Value::Text(UnicodeString::new_nul(&s.name))).with("Idnt", Value::Text(UnicodeString::new_nul(&s.id))); + let mut body = VersionedDescriptor::new(identity).to_bytes(); + body.extend(VersionedDescriptor::new(s.style.clone()).to_bytes()); + body.resize(body.len().div_ceil(4) * 4, 0); + let len = u32::try_from(body.len()).map_err(|_| PsdError::LimitExceeded("style larger than 4 GB"))?; + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&body); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::Id; + + fn pattern() -> PsdPattern { + PsdPattern { + mode: 3, + width: 3, + height: 2, + name: "Dots".into(), + id: "a1b2".into(), + palette: None, + depth: 8, + channels: vec![vec![1, 2, 3, 4, 5, 6], vec![9; 6], vec![200; 6]], + alpha: None, + } + } + + fn style(name: &str) -> AslStyle { + let lefx = Descriptor::new("null").with("masterFXSwitch", Value::Boolean(true)).with( + "SoFi", + Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 50.0 })), + ); + let blend = Descriptor::new("blendOptions") + .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) + .with("fillOpacity", Value::UnitFloat { unit: *b"#Prc", value: 25.0 }); + AslStyle { + name: name.into(), + id: format!("id-{name}"), + style: Descriptor::new("Styl") + .with("documentMode", Value::Descriptor(Descriptor::new("documentMode"))) + .with("Lefx", Value::Descriptor(lefx)) + .with("blendOptions", Value::Descriptor(blend)), + } + } + + fn sample() -> AslFile { + AslFile { patterns: vec![pattern()], pattern_error: None, styles: vec![style("Gold"), style(""), style("Ünïcode ✓")] } + } + + #[test] + fn round_trip() { + let f = sample(); + let b = write(&f).unwrap(); + assert_eq!(&b[..8], b"\0\x028BSL\0\x03"); + assert_eq!(b.len() % 4, 0); + assert_eq!(parse(&b).unwrap(), f); + let empty = AslFile::default(); + assert_eq!(parse(&write(&empty).unwrap()).unwrap(), empty); + } + + #[test] + fn record_lengths_without_padding_also_read() { + // A writer that leaves the padding out of each record length. + let f = sample(); + let mut out = write(&AslFile { styles: vec![], ..f.clone() }).unwrap(); + out.truncate(out.len() - 4); + out.extend_from_slice(&(f.styles.len() as u32).to_be_bytes()); + for s in &f.styles { + let identity = + Descriptor::new("null").with("Nm ", Value::Text(UnicodeString::new_nul(&s.name))).with("Idnt", Value::Text(UnicodeString::new_nul(&s.id))); + let mut body = VersionedDescriptor::new(identity).to_bytes(); + body.extend(VersionedDescriptor::new(s.style.clone()).to_bytes()); + body.push(7); // odd length + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + let pad = (4 - body.len() % 4) % 4; + out.extend_from_slice(&body); + out.extend(std::iter::repeat_n(0, pad)); + } + assert_eq!(parse(&out).unwrap().styles, f.styles); + } + + #[test] + fn bad_pattern_section_is_skipped() { + let mut b = write(&AslFile { patterns: vec![], pattern_error: None, styles: vec![style("A")] }).unwrap(); + // Replace the empty pattern section with 8 bytes of garbage. + let tail = b.split_off(12); + b.truncate(8); + b.extend_from_slice(&8u32.to_be_bytes()); + b.extend_from_slice(&[0, 0, 0, 4, 0xff, 0xff, 0xff, 0xff]); + b.extend(tail); + let f = parse(&b).unwrap(); + assert!(f.pattern_error.is_some()); + assert_eq!(f.styles.len(), 1); + } + + #[test] + fn hostile_input_errors() { + assert!(parse(b"").is_err()); + assert!(parse(b"\0\x028BSL\0\x02").is_err()); + assert!(parse(b"\0\x028BPS\0\x03").is_err()); + // A count far beyond the data. + let mut b = b"\0\x028BSL\0\x03\0\0\0\0".to_vec(); + b.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(parse(&b).is_err()); + // A pattern length beyond the data. + assert!(parse(b"\0\x028BSL\0\x03\xff\xff\xff\xff").is_err()); + let b = write(&sample()).unwrap(); + for cut in 0..b.len() { + assert!(parse(&b[..cut]).is_err(), "cut at {cut}"); + } + for i in 0..b.len() { + for x in [0x5a, 0xff, 0x01] { + let mut c = b.clone(); + c[i] ^= x; + let _ = parse(&c); + } + } + // Seeded random noise after a valid header. + let mut seed = 0x1234_5678u32; + for _ in 0..500 { + let mut c = b"\0\x028BSL\0\x03".to_vec(); + for _ in 0..(seed % 200) { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + c.push(seed as u8); + } + let _ = parse(&c); + } + } +} diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index fab262732b..eec73ed072 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -39,6 +39,14 @@ pub struct GlobalLayerMask { } impl GlobalLayerMask { + /// The mask Photoshop writes for a layered file that has no global mask of its own: + /// an RGB red overlay at 50% opacity, kind 128 (use the per-layer setting), plus the + /// filler byte. A zero-length `LMsk` block in a TIFF makes Photoshop give up on the + /// layer data (it reads this 14-byte record from the block). + pub fn photoshop_default() -> Self { + GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] } + } + fn be16(&self, at: usize) -> Option { self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]])) } @@ -352,12 +360,12 @@ impl PsdFile { } /// `true` if the merged image carries a transparency channel: its first extra channel, when - /// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`, - /// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive - /// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency - /// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit - /// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that - /// name with one name per extra channel marks it too. + /// the layer count was stored negative or a "Saving Merged Transparency" global block (see + /// [`TaggedBlock::is_merged_transparency_marker`]) is present. Photoshop marks 32-bit documents + /// with `Mt32` and a positive layer count (psd-tools transparentbg.psd). Photoshop also names + /// the merged transparency "Transparency" and lists it first among the alpha channel names, + /// and some of its 16-bit PSBs carry no other mark (#4654): when the layers sit in a global + /// `Lr16`/`Lr32` block, that name with one name per extra channel marks it too. pub fn merged_has_alpha(&self) -> bool { let Some(color) = self.header.color_mode.color_channels() else { return false; @@ -365,7 +373,7 @@ impl PsdFile { if self.header.channels <= color { return false; } - let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")); + let marked = self.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker); match &self.layer_info { Some(li) => { let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); @@ -583,4 +591,27 @@ mod write_tests { } } } + + /// A positive layer count plus a merged-transparency marker still makes the first extra + /// channel the transparency, for the specification's keys and for the `MTrn` Photoshop + /// writes in a layered TIFF. + #[test] + fn merged_transparency_markers_mark_the_first_extra_channel() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + if let Some(li) = &mut file.layer_info { + li.merged_alpha = false; + } + assert!(!file.merged_has_alpha()); + for key in [*b"Mtrn", *b"MTrn", *b"Mt16", *b"Mt32"] { + let mut marked = file.clone(); + marked.global_blocks.push(TaggedBlock::new(key, Vec::new())); + assert!(marked.merged_has_alpha(), "{}", String::from_utf8_lossy(&key)); + } + // Other keys, and the marker's letters in another case, do not. + for key in [*b"MTRN", *b"mtrn", *b"Lr16"] { + let mut other = file.clone(); + other.global_blocks.push(TaggedBlock::new(key, Vec::new())); + assert!(!other.merged_has_alpha(), "{}", String::from_utf8_lossy(&key)); + } + } } diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 6fb47cbdb9..5248456d52 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -44,6 +44,7 @@ pub mod abr; pub mod aco; pub mod act; pub mod ase; +pub mod asl; pub mod atn; pub mod blend; pub mod builder; diff --git a/crates/psd/src/metadata.rs b/crates/psd/src/metadata.rs index 9e85d8cee7..24d2eebca6 100644 --- a/crates/psd/src/metadata.rs +++ b/crates/psd/src/metadata.rs @@ -1,11 +1,12 @@ //! `shmd` (metadata setting) layer block: a list of keyed sub-blocks, such as `cmls` (layer comp -//! state, a versioned descriptor), `cust` and `mlst`. +//! state, a versioned descriptor), `cust` (the layer's modify time and XMP packet) and `mlst`. //! //! Layout (Adobe PSD spec, "Metadata setting"): u32 count, then per item the signature `8BIM`, //! a 4-byte key, a copy-on-sheet-duplication byte, 3 padding bytes, a u32 length and the data. //! Photoshop pads item data to an even length (the padding is part of the stored length), so //! parsing and writing are exact: `write(parse(b)) == b`. +use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt}; @@ -71,10 +72,166 @@ pub fn write_shmd(items: &[MetadataItem]) -> Vec { out } +/// The `shmd` item that holds a layer's own metadata (its modify time and XMP). +const CUST: [u8; 4] = *b"cust"; +/// Key of a layer's XMP packet in the `cust` descriptor. +const LAYER_XMP: &str = "layerXMP"; + +// A layer's XMP packet (Photoshop's `layer.xmpMetadata.rawData`), as observed in files Photoshop +// 2026 saves: a `TEXT` item `layerXMP` (UTF-16 with a terminating NUL) in the `metadata` +// descriptor (version 16, class name a lone NUL) of the `shmd` item `cust`, after the +// `layerTime` double that every Photoshop layer has. A layer without XMP has no `layerXMP` item. + +fn pad_even(mut v: Vec) -> Vec { + if v.len() % 2 == 1 { + v.push(0); + } + v +} + +/// The `cust` descriptor of `item`, or `None` when it isn't one. +fn cust_descriptor(item: &MetadataItem) -> Option { + (item.key == CUST).then(|| VersionedDescriptor::parse_prefix(&item.data).ok().map(|(d, _)| d)).flatten() +} + +/// The XMP packet stored on a layer whose `shmd` block data is `shmd`, or `None` when it has +/// none (or the block can't be read). +pub fn layer_xmp(shmd: &[u8]) -> Option { + let items = parse_shmd(shmd).ok()?; + items.iter().filter_map(cust_descriptor).find_map(|d| match d.descriptor.get(LAYER_XMP) { + Some(Value::Text(t)) => Some(t.to_string_lossy()), + _ => None, + }) +} + +/// Splits a layer's XMP packet out of its `shmd` block data: the packet, and the block without +/// it (`None` when nothing is left). `None` when the block holds no packet, so the caller keeps +/// the original bytes. A `cust` item left empty is dropped, the other items are kept as stored. +pub fn take_layer_xmp(shmd: &[u8]) -> Option<(String, Option>)> { + let mut items = parse_shmd(shmd).ok()?; + let (i, mut vd) = items.iter().enumerate().find_map(|(i, it)| cust_descriptor(it).filter(|d| d.descriptor.get(LAYER_XMP).is_some()).map(|d| (i, d)))?; + let at = vd.descriptor.items.iter().position(|(k, _)| k.is(LAYER_XMP))?; + let xmp = match vd.descriptor.items.remove(at).1 { + Value::Text(t) => t.to_string_lossy(), + _ => return None, + }; + if vd.descriptor.items.is_empty() { + items.remove(i); + } else { + items[i].data = pad_even(vd.to_bytes()); + } + Some((xmp, (!items.is_empty()).then(|| write_shmd(&items)))) +} + +/// `shmd` block data (`shmd`, or a new block when `None`) with `xmp` as the layer's XMP packet, +/// written as Photoshop writes it: it replaces an existing packet in place, or is appended to +/// the `cust` descriptor (a new `cust` item when there is none). An unreadable block is an +/// error, so the caller never replaces data it couldn't read. +pub fn put_layer_xmp(shmd: Option<&[u8]>, xmp: &str) -> Result> { + let mut items = match shmd { + Some(b) => parse_shmd(b)?, + None => Vec::new(), + }; + let value = Value::Text(UnicodeString::new_nul(xmp)); + match items.iter().position(|it| it.key == CUST) { + Some(i) => { + let (mut vd, _) = VersionedDescriptor::parse_prefix(&items[i].data)?; + match vd.descriptor.items.iter_mut().find(|(k, _)| k.is(LAYER_XMP)) { + Some(entry) => entry.1 = value, + None => vd.descriptor.items.push((Id::new(LAYER_XMP), value)), + } + items[i].data = pad_even(vd.to_bytes()); + } + None => { + let d = Descriptor { name: UnicodeString::new_nul(""), class_id: Id::new("metadata"), items: vec![(Id::new(LAYER_XMP), value)] }; + items.push(MetadataItem::new(CUST, VersionedDescriptor::new(d).to_bytes())); + } + } + Ok(write_shmd(&items)) +} + #[cfg(test)] mod tests { use super::*; + fn unhex(lines: &[&str]) -> Vec { + let h: String = lines.concat(); + (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect() + } + + /// `shmd` of a layer saved by Photoshop 2026 after a script set its XMP to [`PS_PACKET`]. + const PS_WITH_XMP: &[&str] = &[ + "000000013842494d63757374000000000000024800000010000000010000000000086d65746164617461000000020000", + "00096c6179657254696d65646f756241dab643ffd54363000000086c61796572584d505445585400000100003c007800", + "3a0078006d0070006d00650074006100200078006d006c006e0073003a0078003d002200610064006f00620065003a00", + "6e0073003a006d006500740061002f0022003e003c007200640066003a00520044004600200078006d006c006e007300", + "3a007200640066003d00220068007400740070003a002f002f007700770077002e00770033002e006f00720067002f00", + "31003900390039002f00300032002f00320032002d007200640066002d00730079006e007400610078002d006e007300", + "230022003e003c007200640066003a004400650073006300720069007000740069006f006e0020007200640066003a00", + "610062006f00750074003d0022002200200078006d006c006e0073003a00730061006d0070006c0065003d0022006800", + "7400740070003a002f002f006500780061006d0070006c0065002e0063006f006d002f00730061006d0070006c006500", + "2f0031002e0030002f0022003e003c00730061006d0070006c0065003a00760061006c00750065003e0061006c007000", + "680061002d003100320033003c002f00730061006d0070006c0065003a00760061006c00750065003e003c002f007200", + "640066003a004400650073006300720069007000740069006f006e003e003c002f007200640066003a00520044004600", + "3e003c002f0078003a0078006d0070006d006500740061003e000000", + ]; + /// `shmd` of a layer without XMP in the same file (its own `layerTime` only). + const PS_WITHOUT_XMP: &[&str] = + &["000000013842494d63757374000000000000003400000010000000010000000000086d6574616461746100000001000000096c6179657254696d65646f756241dab643ffd86bec00"]; + const PS_PACKET: &str = concat!( + r#""#, + r#"alpha-123"#, + r#""# + ); + + #[test] + fn reads_photoshop_layer_xmp() { + assert_eq!(layer_xmp(&unhex(PS_WITH_XMP)).as_deref(), Some(PS_PACKET)); + assert_eq!(layer_xmp(&unhex(PS_WITHOUT_XMP)), None); + assert!(take_layer_xmp(&unhex(PS_WITHOUT_XMP)).is_none()); + } + + #[test] + fn take_then_put_is_exact() { + let ps = unhex(PS_WITH_XMP); + let (xmp, rest) = take_layer_xmp(&ps).unwrap(); + assert_eq!(xmp, PS_PACKET); + let rest = rest.unwrap(); + // What's left is a layer without XMP, shaped as Photoshop writes one. + assert_eq!(rest.len(), unhex(PS_WITHOUT_XMP).len()); + assert_eq!(layer_xmp(&rest), None); + assert_eq!(put_layer_xmp(Some(&rest), &xmp).unwrap(), ps); + } + + #[test] + fn put_replaces_in_place_and_creates_a_block() { + let ps = unhex(PS_WITH_XMP); + let edited = put_layer_xmp(Some(&ps), "").unwrap(); + assert_eq!(layer_xmp(&edited).as_deref(), Some("")); + // `layerTime` is kept, still first. + let items = parse_shmd(&edited).unwrap(); + let d = cust_descriptor(&items[0]).unwrap().descriptor; + assert!(d.items[0].0.is("layerTime") && d.items[1].0.is(LAYER_XMP) && d.items.len() == 2); + + let fresh = put_layer_xmp(None, PS_PACKET).unwrap(); + assert_eq!(layer_xmp(&fresh).as_deref(), Some(PS_PACKET)); + assert_eq!(take_layer_xmp(&fresh), Some((PS_PACKET.to_string(), None))); + // Other items (here a layer comp state) stay as they were. + let comps = write_shmd(&[MetadataItem::new(*b"cmls", vec![1, 2])]); + let both = put_layer_xmp(Some(&comps), PS_PACKET).unwrap(); + assert_eq!(parse_shmd(&both).unwrap()[0], parse_shmd(&comps).unwrap()[0]); + assert_eq!(take_layer_xmp(&both).unwrap().1.unwrap(), comps); + } + + #[test] + fn put_refuses_an_unreadable_block() { + assert!(put_layer_xmp(Some(&[0, 0, 0, 9]), PS_PACKET).is_err()); + let bad_cust = write_shmd(&[MetadataItem::new(CUST, vec![0, 0, 0, 16, 1])]); + assert!(put_layer_xmp(Some(&bad_cust), PS_PACKET).is_err()); + assert_eq!(layer_xmp(&bad_cust), None); + assert!(take_layer_xmp(&bad_cust).is_none()); + } + #[test] fn roundtrip_exact() { let items = vec![MetadataItem::new(*b"cmls", vec![1, 2, 3]), MetadataItem { copy: true, ..MetadataItem::new(*b"cust", vec![9; 4]) }]; diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 7f845f525e..85bf285db9 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -18,7 +18,9 @@ //! `.pat` file is `8BPT`, version u16 (1), count u32, then the patterns without length prefixes. //! Photoshop writes the channel count as 24 and only marks the real channels as written; like //! ag-psd (MIT) we take the first written arrays as the colour channels and the next one as -//! transparency. +//! transparency. Photoshop stores a pattern's transparency in the last (sheet mask) array and +//! leaves the user mask unwritten (psd-tools multichannel-pattern-fill.psd, Photoshop CC 2019); a +//! file with transparency in the user mask array is refused by Photoshop (#8054). use crate::compression::{Compression, MAX_DECODED_BYTES, PlaneLayout, decode_planes, encode_planes}; use crate::error::{PsdError, Result}; @@ -56,6 +58,12 @@ impl PatternBudget { PatternBudget { decoded_left: d.decoded_left.min(decoded), padded_left: d.padded_left.min(decoded) } } + /// Cap both decoded and padded bytes to the amount still available to a containing file. + pub fn cap_decoded_to(&mut self, decoded: u64) { + self.decoded_left = self.decoded_left.min(decoded); + self.padded_left = self.padded_left.min(decoded); + } + /// Decoded bytes still available. pub fn decoded_left(&self) -> u64 { self.decoded_left @@ -137,7 +145,15 @@ impl<'a> Rd<'a> { } } -fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { +/// A pattern's mode, name, id and palette, up to its virtual memory array list. +struct PatternHeader { + mode: u32, + name: String, + id: String, + palette: Option>, +} + +fn read_header(r: &mut Rd) -> Result { let version = r.u32()?; if version != 1 { return Err(PsdError::invalid(format!("pattern version {version}"))); @@ -163,12 +179,22 @@ fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { } else { None }; + Ok(PatternHeader { mode, name, id, palette }) +} + +/// The body of a pattern's virtual memory array list (after its version and length). +fn read_vma_body<'a>(r: &mut Rd<'a>) -> Result<&'a [u8]> { let vver = r.u32()?; if vver != 3 { return Err(PsdError::invalid(format!("virtual memory array list version {vver}"))); } let len = r.u32()? as usize; - let body = r.take(len)?; + r.take(len) +} + +fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { + let PatternHeader { mode, name, id, palette } = read_header(r)?; + let body = read_vma_body(r)?; let mut v = Rd { b: body, p: 0 }; let [top, left, bottom, right] = v.rect()?; let w = right.checked_sub(left).ok_or(PsdError::LimitExceeded("pattern width overflow"))?; @@ -365,6 +391,10 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { out.extend_from_slice(&(p.height as u16).to_be_bytes()); out.extend_from_slice(&(p.width as u16).to_be_bytes()); let units: Vec = p.name.encode_utf16().chain(std::iter::once(0)).collect(); + // The reader refuses longer names (see `read_pattern`). + if units.len() > 65_536 { + return Err(PsdError::LimitExceeded("pattern name length")); + } out.extend_from_slice(&(units.len() as u32).to_be_bytes()); for u in units { out.extend_from_slice(&u.to_be_bytes()); @@ -407,12 +437,12 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { for _ in nc..SLOTS as usize { body.extend_from_slice(&0u32.to_be_bytes()); } - // User mask slot: transparency; sheet mask slot: unused. + // User mask slot: unused; sheet mask slot: transparency, where Photoshop keeps it (#8054). + body.extend_from_slice(&0u32.to_be_bytes()); match &p.alpha { Some(a) => array(a, &mut body)?, None => body.extend_from_slice(&0u32.to_be_bytes()), } - body.extend_from_slice(&0u32.to_be_bytes()); out.extend_from_slice(&3u32.to_be_bytes()); out.extend_from_slice(&(body.len() as u32).to_be_bytes()); out.extend_from_slice(&body); @@ -425,9 +455,8 @@ pub fn parse_pattern_block(data: &[u8]) -> Result> { } /// [`parse_pattern_block`] charging `budget`, so several blocks of one document share one limit. -/// The budget is charged only for patterns that are returned. +/// Charges are monotonic even when a later record in the block fails. pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Result> { - let mut left = budget.clone(); let mut r = Rd { b: data, p: 0 }; let mut out = Vec::new(); while r.p + 4 <= data.len() { @@ -436,14 +465,55 @@ pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Resu break; } let body = r.take(len)?; - out.push(read_pattern(&mut Rd { b: body, p: 0 }, &mut left)?); + out.push(read_pattern(&mut Rd { b: body, p: 0 }, budget)?); let pad = (4 - len % 4) % 4; r.p = (r.p + pad).min(data.len()); } - *budget = left; Ok(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`. +pub fn block_has_user_mask(data: &[u8]) -> bool { + let mut r = Rd { b: data, p: 0 }; + while r.p + 4 <= data.len() { + let Ok(len) = r.u32() else { return false }; + if len == 0 { + break; + } + let Ok(body) = r.take(len as usize) else { return false }; + if pattern_has_user_mask(&mut Rd { b: body, p: 0 }).unwrap_or(false) { + return true; + } + let pad = (4 - len as usize % 4) % 4; + r.p = (r.p + pad).min(data.len()); + } + false +} + +fn pattern_has_user_mask(r: &mut Rd) -> Result { + read_header(r)?; + let mut v = Rd { b: read_vma_body(r)?, p: 0 }; + v.rect()?; + let count = v.u32()?; + // Every array takes at least 4 bytes, so the body bounds this loop whatever `count` says. + for i in 0..count.saturating_add(2) { + if v.p >= v.b.len() { + break; + } + if v.u32()? == 0 { + continue; + } + let alen = v.u32()? as usize; + if i == count && alen != 0 { + return Ok(true); + } + v.take(alen)?; + } + Ok(false) +} + /// Serializes patterns as `Patt` / `Pat2` / `Pat3` block data. pub fn write_pattern_block(patterns: &[PsdPattern]) -> Result> { let mut out = Vec::new(); @@ -467,6 +537,9 @@ pub fn block_key(depth: u16) -> [u8; 4] { } } +/// The most patterns a `.pat` file may hold: more are refused on read and on write. +pub const MAX_PAT_PATTERNS: usize = 100_000; + /// Parses a `.pat` pattern file. pub fn parse_pat_file(data: &[u8]) -> Result> { let mut r = Rd { b: data, p: 0 }; @@ -475,10 +548,13 @@ pub fn parse_pat_file(data: &[u8]) -> Result> { return Err(PsdError::InvalidSignature { expected: "8BPT", found: [sig[0], sig[1], sig[2], sig[3]] }); } let _version = r.u16()?; - let count = r.u32()?; + let count = r.u32()? as usize; + if count > MAX_PAT_PATTERNS { + return Err(PsdError::LimitExceeded("pattern count")); + } let mut out = Vec::new(); let mut budget = PatternBudget::default(); - for _ in 0..count.min(100_000) { + for _ in 0..count { out.push(read_pattern(&mut r, &mut budget)?); } Ok(out) @@ -486,6 +562,9 @@ pub fn parse_pat_file(data: &[u8]) -> Result> { /// Writes a `.pat` pattern file. pub fn write_pat_file(patterns: &[PsdPattern]) -> Result> { + if patterns.len() > MAX_PAT_PATTERNS { + return Err(PsdError::LimitExceeded("pattern count")); + } let mut out = b"8BPT".to_vec(); out.extend_from_slice(&1u16.to_be_bytes()); out.extend_from_slice(&(patterns.len() as u32).to_be_bytes()); @@ -516,6 +595,28 @@ mod tests { } } + /// #9318 review: a name the reader refuses (more than 65,536 UTF-16 units with its NUL) is + /// refused on write, so a saved library can't become a file that loads nothing. + #[test] + fn a_name_the_reader_refuses_is_refused_on_write() { + let long = PsdPattern { name: "a".repeat(65_536), ..pat(8, false) }; + assert!(matches!(write_pat_file(&[long]), Err(PsdError::LimitExceeded(_)))); + let longest = PsdPattern { name: "a".repeat(65_535), ..pat(8, false) }; + assert_eq!(parse_pat_file(&write_pat_file(std::slice::from_ref(&longest)).unwrap()).unwrap(), [longest]); + } + + /// #9318 review: more patterns than a `.pat` holds are refused on write, and a file claiming + /// more is refused on read rather than silently cut to the first 100,000. + #[test] + fn more_patterns_than_a_pat_file_holds_are_refused() { + let many = vec![pat(8, false); MAX_PAT_PATTERNS + 1]; + assert!(matches!(write_pat_file(&many), Err(PsdError::LimitExceeded(_)))); + let mut header = b"8BPT".to_vec(); + header.extend_from_slice(&1u16.to_be_bytes()); + header.extend_from_slice(&(MAX_PAT_PATTERNS as u32 + 1).to_be_bytes()); + assert!(matches!(parse_pat_file(&header), Err(PsdError::LimitExceeded(_)))); + } + #[test] fn block_and_pat_file_round_trip_at_every_depth() { for depth in [8, 16, 32] { @@ -532,6 +633,47 @@ mod tests { } } + /// Offsets of the virtual memory arrays of a one-pattern block (each at its "written" flag), + /// with whether each is written, and the end of the array list. + fn vma_slots(block: &[u8], p: &PsdPattern) -> (Vec<(usize, bool)>, usize) { + let rect = pattern_vma_rect_offset(4, p); + let end = rect + u32::from_be_bytes(block[rect - 4..rect].try_into().unwrap()) as usize; + let (mut at, mut slots) = (rect + 20, Vec::new()); + while at < end { + let written = u32::from_be_bytes(block[at..at + 4].try_into().unwrap()) != 0; + slots.push((at, written)); + at += 4; + if written { + at += 4 + u32::from_be_bytes(block[at..at + 4].try_into().unwrap()) as usize; + } + } + (slots, end) + } + + #[test] + fn transparency_goes_in_the_sheet_mask_array_like_photoshop() { + // #8054: transparency in the user mask array (slot 24) made Photoshop refuse the file; + // Photoshop keeps it in the sheet mask array (slot 25). + for depth in [8, 16, 32] { + let p = pat(depth, true); + let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); + let (slots, end) = vma_slots(&block, &p); + let written: Vec = slots.iter().enumerate().filter(|(_, s)| s.1).map(|(i, _)| i).collect(); + assert_eq!((slots.len(), written), (26, vec![0, 1, 2, 25]), "{depth}-bit"); + assert!(!block_has_user_mask(&block)); + // The layout older PhotoCraft builds wrote still opens, and is detected. + block[slots[24].0..end].rotate_left(4); + let (slots, _) = vma_slots(&block, &p); + assert!(slots[24].1 && !slots[25].1); + assert!(block_has_user_mask(&block)); + assert_eq!(parse_pattern_block(&block).unwrap(), vec![p]); + } + assert!(!block_has_user_mask(&write_pattern_block(&[pat(8, false)]).unwrap())); + for junk in [&[][..], &[0, 0, 0, 9, 1], &[0xff; 64]] { + assert!(!block_has_user_mask(junk)); + } + } + #[test] fn malformed_input_errors_without_panicking() { let blk = write_pattern_block(&[pat(8, true)]).unwrap(); @@ -729,6 +871,18 @@ mod tests { block } + #[test] + fn failed_pattern_block_keeps_partial_charge_for_the_next_block() { + let one = padded_gray_block(100); + let mut malformed = one.clone(); + malformed.extend_from_slice(&100u32.to_be_bytes()); + let mut budget = PatternBudget::with_decoded_limit(15_000); + assert!(parse_pattern_block_with(&malformed, &mut budget).is_err()); + assert_eq!(budget.decoded_left(), 5_000); + assert!(parse_pattern_block_with(&one, &mut budget).is_err()); + assert_eq!(budget.decoded_left(), 5_000); + } + #[test] fn padding_budget_is_shared_by_every_pattern_of_a_parse() { // Each pattern pads 100x100 = 10000 bytes; a 15000-byte budget fits one, not two. Before @@ -741,8 +895,9 @@ mod tests { let two = [one.clone(), one.clone()].concat(); let mut budget = PatternBudget::with_decoded_limit(15_000); assert!(matches!(parse_pattern_block_with(&two, &mut budget), Err(PsdError::LimitExceeded(_)))); - // A failed parse keeps nothing, so it charges nothing. - assert_eq!(budget.decoded_left(), 15_000); + // Charges are monotonic: the first pattern's 10000 bytes stay charged even when the second + // record fails. + assert_eq!(budget.decoded_left(), 5_000); // Two blocks of one document share a budget too. let mut budget = PatternBudget::with_decoded_limit(15_000); diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index 5af4903c55..47828c82d5 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -245,12 +245,14 @@ impl<'a> Layer<'a> { let rect = self.record.rect; let (w, h) = rect.size()?; let n = w.checked_mul(h).ok_or(PsdError::LimitExceeded("layer size overflow"))?; - if n.checked_mul(4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { - return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); - } let depth = self.header.depth; let mode = self.header.color_mode; let cc = color_channel_count(mode)?; + // The planes (one per colour channel, filled in for channels the layer lacks, and alpha) + // coexist with the RGBA output, so the cap covers all of them, not the output alone. + if n.checked_mul(cc + 2 + 4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { + return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); + } let mut color = Vec::with_capacity(cc); for id in 0..cc as i16 { let plane = if self.record.channel(id).is_some() { diff --git a/crates/psd/src/slices.rs b/crates/psd/src/slices.rs index 54bf4c8fcd..5d9cd50b39 100644 --- a/crates/psd/src/slices.rs +++ b/crates/psd/src/slices.rs @@ -10,6 +10,8 @@ //! version reads. Unknown fields are not kept here; callers that need a byte-exact round trip //! keep the raw resource while the slices are unchanged. +use std::collections::HashMap; + use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt}; @@ -274,13 +276,22 @@ impl SlicesResource { && let Ok(vd) = VersionedDescriptor::read(r) && let Some(Value::List(items)) = vd.descriptor.get("slices") { + // Index each slice ID to its first occurrence once, so matching trailing + // descriptors against slices is linear rather than quadratic. Both the slice + // count and the descriptor-ID list are file-controlled, so a plain + // `find()` per descriptor is O(n*m) and lets a crafted resource burn CPU. + // `or_insert` preserves the original first-match behavior for duplicate IDs. + let mut id_to_index: HashMap = HashMap::with_capacity(slices.len()); + for (i, s) in slices.iter().enumerate() { + id_to_index.entry(s.id).or_insert(i); + } for v in items { let Value::Descriptor(d) = v else { continue }; // An entry without a usable id belongs to no slice (not to slice 0). let Some(id) = d_u32(d, "sliceID") else { continue }; let extra = slice_from_descriptor(d); - if let Some(s) = slices.iter_mut().find(|s| s.id == id) { - s.outsets = extra.outsets; + if let Some(&i) = id_to_index.get(&id) { + slices[i].outsets = extra.outsets; } } } @@ -493,4 +504,28 @@ mod tests { assert!(SlicesResource::from_bytes(&huge).is_err()); assert!(SlicesResource::from_bytes(&9u32.to_be_bytes()).is_err()); } + + #[test] + fn trailing_descriptor_applies_outsets_to_first_matching_slice() { + // Two slices share id 1; the trailing descriptor must update the first one only, + // matching the pre-index behavior of `iter_mut().find()`. + let mut plain = SlicesResource { + version: 6, + bounds: [0, 0, 80, 100], + group_name: "site".into(), + slices: vec![ + SliceRecord { id: 1, origin: 0, kind: 1, rect: [0, 0, 10, 10], ..Default::default() }, + SliceRecord { id: 1, origin: 0, kind: 1, rect: [10, 10, 20, 20], ..Default::default() }, + ], + }; + for s in &mut plain.slices { + s.outsets = [0; 4]; + } + let mut bytes = plain.to_bytes(); + let extra = Descriptor::new("slice").with("sliceID", Value::Integer(1)).with("topOutset", Value::Integer(7)); + VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(extra)]))).write(&mut bytes); + let back = SlicesResource::from_bytes(&bytes).unwrap(); + assert_eq!(back.slices[0].outsets, [7, 0, 0, 0], "first slice receives the outset"); + assert_eq!(back.slices[1].outsets, [0, 0, 0, 0], "later duplicate is untouched"); + } } diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 7728d53fd1..b16e158565 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -155,6 +155,14 @@ impl TaggedBlock { Self::is_foreign_order_signature(&self.signature) } + /// `true` for a "Saving Merged Transparency" marker: the merged image's first extra channel + /// is its transparency. The specification's keys are `Mtrn`, `Mt16` and `Mt32`; Photoshop + /// (27.11) writes `MTrn`, with a positive layer count, in a layered TIFF saved with Save + /// Transparency next to spot and alpha channels. + pub fn is_merged_transparency_marker(&self) -> bool { + matches!(&self.key, b"Mtrn" | b"MTrn" | b"Mt16" | b"Mt32") + } + /// Key as a string (lossy). pub fn key_str(&self) -> String { String::from_utf8_lossy(&self.key).into_owned() diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 9a2cfe5285..8b11a6b61e 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -1162,16 +1162,16 @@ pub mod transcode { fn type_layer(&mut self) -> Result<()> { self.u16()?; self.f64s(6)?; - self.type_descriptor(50)?; - self.type_descriptor(1)?; + self.type_descriptor(50, ByteOrder::Big)?; + self.type_descriptor(1, self.to)?; self.i32s(4) } - fn type_descriptor(&mut self, version: u16) -> Result<()> { + fn type_descriptor(&mut self, version: u16, to: ByteOrder) -> Result<()> { let [hi, lo] = version.to_be_bytes(); let from = if self.from.is_little() && self.r.peek_rest().starts_with(&[hi, lo, 0, 0, 0, 16]) { ByteOrder::Big } else { self.from }; let data = self.r.peek_rest(); - let mut descriptor = Tx::new(data, self.version, from, self.to); + let mut descriptor = Tx::new(data, self.version, from, to); descriptor.depth = self.depth; descriptor.u16()?; descriptor.versioned_descriptor()?; @@ -1836,6 +1836,132 @@ mod tests { block_rt(*b"lfx2", data); } + fn type_layer_data() -> Vec { + let mut data = Vec::new(); + data.put_u16(1); + for value in [1.5, 0.0, -0.25, 1.75, 12.5, -3.25] { + data.put_f64(value); + } + data.put_u16(50); + let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic Ω 🦀"))); + data.extend(VersionedDescriptor::new(text).to_bytes()); + data.put_u16(1); + data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes()); + for value in [11, -22, 333, 4_444] { + data.put_i32(value); + } + data + } + + fn tagged_payload<'a>(bytes: &'a [u8], signature_and_key: &[u8; 8], little: bool) -> &'a [u8] { + let block_at = bytes.windows(8).position(|window| window == signature_and_key).expect("tagged block"); + let length_at = block_at + 8; + let length_bytes: [u8; 4] = bytes.get(length_at..length_at + 4).expect("tagged block length").try_into().expect("four-byte length"); + let length = if little { u32::from_le_bytes(length_bytes) } else { u32::from_be_bytes(length_bytes) }; + let data_at = length_at + 4; + let data_end = data_at + usize::try_from(length).expect("test block length fits usize"); + bytes.get(data_at..data_end).expect("tagged block payload") + } + + fn legacy_little_endian_type_block() -> Vec { + let mut tysh = Vec::new(); + tysh.extend_from_slice(&1u16.to_le_bytes()); + for value in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25] { + tysh.extend_from_slice(&value.to_le_bytes()); + } + tysh.extend_from_slice(&50u16.to_le_bytes()); + // Independently encode a little-endian `VersionedDescriptor` with a single TEXT value. + tysh.extend_from_slice(&16u32.to_le_bytes()); + tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name + tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character class id + tysh.extend_from_slice(b"rLxT"); // little-endian four-character class id + tysh.extend_from_slice(&1u32.to_le_bytes()); // one item + tysh.extend_from_slice(&0u32.to_le_bytes()); // four-character key + tysh.extend_from_slice(b" txT"); // little-endian four-character key + tysh.extend_from_slice(b"TXET"); // little-endian OSType + let mut units: Vec = "Synthetic Ω 🦀".encode_utf16().collect(); + units.push(0); // `new_nul`, matching the canonical fixture + tysh.extend_from_slice(&u32::try_from(units.len()).expect("short test string").to_le_bytes()); + for unit in units { + tysh.extend_from_slice(&unit.to_le_bytes()); + } + tysh.extend_from_slice(&1u16.to_le_bytes()); + // Empty warp descriptor, also encoded independently in little-endian order. + tysh.extend_from_slice(&16u32.to_le_bytes()); + tysh.extend_from_slice(&0u32.to_le_bytes()); // empty descriptor name + tysh.extend_from_slice(&4u32.to_le_bytes()); // `warp` is a four-byte string id + tysh.extend_from_slice(b"warp"); + tysh.extend_from_slice(&0u32.to_le_bytes()); // no items + for value in [11i32, -22, 333, 4_444] { + tysh.extend_from_slice(&value.to_le_bytes()); + } + + let mut image_source = super::SIGNATURE.to_vec(); + image_source.extend_from_slice(b"MIB8hSyT"); + image_source.extend_from_slice(&u32::try_from(tysh.len()).expect("short test block").to_le_bytes()); + image_source.extend_from_slice(&tysh); + let padding = (4 - tysh.len() % 4) % 4; + image_source.resize(image_source.len() + padding, 0); + image_source + } + + #[test] + fn type_block_write_keeps_text_descriptor_in_psd_order() { + let canonical = type_layer_data(); + let source = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"TySh", canonical.clone())], ..Default::default() }; + + let (little, warnings) = source.to_bytes(ByteOrder::Little).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let payload = tagged_payload(&little, b"MIB8hSyT", true); + assert_eq!(payload.get(..2), Some(&[1, 0][..]), "TySh header stays in TIFF order"); + for (index, expected) in [1.5f64, 0.0, -0.25, 1.75, 12.5, -3.25].into_iter().enumerate() { + let start = 2 + index * 8; + let bytes: [u8; 8] = payload.get(start..start + 8).unwrap().try_into().unwrap(); + assert_eq!(f64::from_le_bytes(bytes), expected, "matrix element {index}"); + } + + let (text_descriptor, descriptor_len) = VersionedDescriptor::parse_prefix(canonical.get(52..).unwrap()).unwrap(); + let text_section_len = 2 + descriptor_len; + assert_eq!(payload.get(50..52), Some(&50u16.to_be_bytes()[..])); + assert_eq!(payload.get(52..56), Some(&16u32.to_be_bytes()[..])); + assert_eq!(payload.get(50..50 + text_section_len), canonical.get(50..50 + text_section_len)); + assert!(matches!(text_descriptor.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀")); + let text_descriptor_from_output = VersionedDescriptor::parse_prefix(payload.get(52..).unwrap()).unwrap().0; + assert!(matches!(text_descriptor_from_output.descriptor.get("Txt "), Some(Value::Text(value)) if value.to_string_lossy() == "Synthetic Ω 🦀")); + + let warp_at = 50 + text_section_len; + assert_eq!(payload.get(warp_at..warp_at + 2), Some(&1u16.to_le_bytes()[..])); + let (_, warp_len) = VersionedDescriptor::parse_prefix(canonical.get(warp_at + 2..).unwrap()).unwrap(); + let warp_descriptor_at = warp_at + 2; + assert_eq!(payload.get(warp_descriptor_at..warp_descriptor_at + 4), Some(&16u32.to_le_bytes()[..])); + assert_eq!(payload.get(warp_descriptor_at + 4..warp_descriptor_at + 8), Some(&0u32.to_le_bytes()[..])); + assert_eq!(payload.get(warp_descriptor_at + 8..warp_descriptor_at + 12), Some(&4u32.to_le_bytes()[..])); + assert_eq!(payload.get(warp_descriptor_at + 12..warp_descriptor_at + 16), Some(b"warp".as_slice())); + assert_eq!(payload.get(warp_descriptor_at + 16..warp_descriptor_at + 20), Some(&0u32.to_le_bytes()[..])); + let bounds_at = warp_descriptor_at + warp_len; + for (index, expected) in [11i32, -22, 333, 4_444].into_iter().enumerate() { + let start = bounds_at + index * 4; + assert_eq!(payload.get(start..start + 4), Some(&expected.to_le_bytes()[..]), "bound {index}"); + } + + let (big, warnings) = source.to_bytes(ByteOrder::Big).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(tagged_payload(&big, b"8BIMTySh", false), canonical); + + let (reopened, warnings) = ImageSourceData::from_bytes(&little).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(reopened.global_blocks[0].data, canonical); + assert_eq!(reopened.to_bytes(ByteOrder::Big).unwrap().0, big); + } + + #[test] + fn legacy_little_endian_type_descriptor_still_imports() { + let input = legacy_little_endian_type_block(); + let (reopened, warnings) = ImageSourceData::from_bytes(&input).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(reopened.global_blocks[0].data, type_layer_data()); + } + #[test] fn type_block_layout() { let mut data = Vec::new(); diff --git a/crates/psd/tests/builder.rs b/crates/psd/tests/it/builder.rs similarity index 100% rename from crates/psd/tests/builder.rs rename to crates/psd/tests/it/builder.rs diff --git a/crates/psd/tests/corpus.rs b/crates/psd/tests/it/corpus.rs similarity index 100% rename from crates/psd/tests/corpus.rs rename to crates/psd/tests/it/corpus.rs diff --git a/crates/psd/tests/corpus_atn.rs b/crates/psd/tests/it/corpus_atn.rs similarity index 100% rename from crates/psd/tests/corpus_atn.rs rename to crates/psd/tests/it/corpus_atn.rs diff --git a/crates/psd/tests/layer_block_order.rs b/crates/psd/tests/it/layer_block_order.rs similarity index 100% rename from crates/psd/tests/layer_block_order.rs rename to crates/psd/tests/it/layer_block_order.rs diff --git a/crates/psd/tests/it/main.rs b/crates/psd/tests/it/main.rs new file mode 100644 index 0000000000..ee6879aa66 --- /dev/null +++ b/crates/psd/tests/it/main.rs @@ -0,0 +1,12 @@ +//! The crate's integration tests, linked into one test binary. + +mod builder; +mod corpus; +mod corpus_atn; +mod layer_block_order; +mod malformed; +mod merged_recovery; +mod proptests; +mod roundtrip; +mod swatch_malformed; +mod tree; diff --git a/crates/psd/tests/malformed.rs b/crates/psd/tests/it/malformed.rs similarity index 100% rename from crates/psd/tests/malformed.rs rename to crates/psd/tests/it/malformed.rs diff --git a/crates/psd/tests/merged_recovery.rs b/crates/psd/tests/it/merged_recovery.rs similarity index 100% rename from crates/psd/tests/merged_recovery.rs rename to crates/psd/tests/it/merged_recovery.rs diff --git a/crates/psd/tests/proptests.rs b/crates/psd/tests/it/proptests.rs similarity index 100% rename from crates/psd/tests/proptests.rs rename to crates/psd/tests/it/proptests.rs diff --git a/crates/psd/tests/roundtrip.rs b/crates/psd/tests/it/roundtrip.rs similarity index 99% rename from crates/psd/tests/roundtrip.rs rename to crates/psd/tests/it/roundtrip.rs index 1f23ee6284..49ad412e7c 100644 --- a/crates/psd/tests/roundtrip.rs +++ b/crates/psd/tests/it/roundtrip.rs @@ -232,6 +232,7 @@ fn global_mask_accessors() { assert_eq!(gm.opacity(), Some(50)); assert_eq!(gm.kind(), Some(128)); assert_eq!(GlobalLayerMask::default().kind(), None); + assert_eq!(GlobalLayerMask::photoshop_default().data, gm.data); } #[test] diff --git a/crates/psd/tests/swatch_malformed.rs b/crates/psd/tests/it/swatch_malformed.rs similarity index 100% rename from crates/psd/tests/swatch_malformed.rs rename to crates/psd/tests/it/swatch_malformed.rs diff --git a/crates/psd/tests/tree.rs b/crates/psd/tests/it/tree.rs similarity index 100% rename from crates/psd/tests/tree.rs rename to crates/psd/tests/it/tree.rs diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index df4886dfb0..ce70830f37 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -14,7 +14,7 @@ use std::collections::BTreeMap; use std::fmt; use std::sync::Arc; -use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, write_sample}; +use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, read_samples, write_sample}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; /// A checked surface allocation could not be represented or reserved. @@ -245,6 +245,56 @@ 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) { + 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]; + 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]; + 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]; + } + } + (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]; + 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 { + Some((key, rgb)) if key == ink => rgb, + _ => { + let rgb = photocraft_color::convert::cmyk_to_rgb(ink.map(|v| v as f32 / 255.0)); + previous = Some((ink, rgb)); + rgb + } + }; + *d = [rgb[0], rgb[1], rgb[2], s.get(4).map_or(1.0, |v| *v as f32 / 255.0)]; + } + } + _ => { + // The row's samples in one pass, then its colour model resolved once: + // RGB and grey take their channels as `to_rgba` does, other models go + // through it. + row.resize(dst.len() * n, 0.0); + read_samples(&t.data, fmt.sample, base, row); + let a = |s: &[f32], i: usize| if fmt.alpha { s[i] } else { 1.0 }; + let pixels = dst.iter_mut().zip(row.chunks_exact(n)); + match fmt.mode { + ColorMode::Rgb => pixels.for_each(|(d, s)| *d = [s[0], s[1], s[2], a(s, 3)]), + ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => pixels.for_each(|(d, s)| *d = [s[0], s[0], s[0], a(s, 1)]), + _ => pixels.for_each(|(d, s)| *d = to_rgba(fmt, s)), + } + } + } +} + impl Surface { /// Reads a region into a newly allocated buffer, returning an error instead of aborting if /// the requested pixel buffer cannot be represented or reserved. @@ -476,30 +526,37 @@ impl Surface { /// Channel `c` at `(x0 + i·step, y)` for `i in 0..out.len()`, looking each tile up once per run /// (much faster than per-sample [`Surface::sample_channel`] for strided scans). + /// Steps below 1 read consecutive pixels; positions beyond `i32::MAX` read the default pixel. pub fn sample_row_strided(&self, y: i32, x0: i32, step: i32, c: usize, out: &mut [f32]) { - let step = step.max(1); + let step = i64::from(step.max(1)); let dp = read_sample(&self.default_pixel, self.format.sample, c); let n = self.format.channels(); let ty = y.div_euclid(TILE_SIZE); let ly = y.rem_euclid(TILE_SIZE) as usize; let mut i = 0; + let mut x = i64::from(x0); while i < out.len() { - let x = x0 + i as i32 * step; - let tx = x.div_euclid(TILE_SIZE); + if x > i64::from(i32::MAX) { + out[i..].fill(dp); + break; + } + let tx = x.div_euclid(i64::from(TILE_SIZE)); // Samples that fall in this tile. - let tile_end = (tx + 1) * TILE_SIZE; + let tile_end = (tx + 1) * i64::from(TILE_SIZE); let count = (((tile_end - x) + step - 1) / step).max(1) as usize; - let end = (i + count).min(out.len()); - match self.tiles.get(&TileCoord { tx, ty }) { + let count = count.min(out.len() - i); + let end = i + count; + match self.tiles.get(&TileCoord { tx: tx as i32, ty }) { None => out[i..end].fill(dp), Some(t) => { for (k, o) in out[i..end].iter_mut().enumerate() { - let lx = (x + k as i32 * step - tx * TILE_SIZE) as usize; + 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); } } } i = end; + x += count as i64 * step; } } @@ -533,9 +590,7 @@ impl Surface { match tile { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; - for (i, d) in dst.iter_mut().enumerate() { - *d = read_sample(&t.data, self.format.sample, base + i); - } + read_samples(&t.data, self.format.sample, base, dst); } None if n == 1 => dst.fill(dp[0]), None => { @@ -559,8 +614,8 @@ impl Surface { let n = fmt.channels(); let mut stack = [0.0; 8]; let mut heap = Vec::new(); - let px = self.default_samples(&mut stack, &mut heap); - let dp = to_rgba(&fmt, px); + let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap)); + let mut row = Vec::new(); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); @@ -572,46 +627,7 @@ impl Surface { continue; }; let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; - match (fmt.mode, fmt.sample, fmt.alpha) { - (ColorMode::Rgb, SampleType::U8, true) => { - let src = &t.data[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]; - 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]; - } - } - (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]; - 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 { - Some((key, rgb)) if key == ink => rgb, - _ => { - let rgb = photocraft_color::convert::cmyk_to_rgb(ink.map(|v| v as f32 / 255.0)); - previous = Some((ink, rgb)); - rgb - } - }; - *d = [rgb[0], rgb[1], rgb[2], s.get(4).map_or(1.0, |v| *v as f32 / 255.0)]; - } - } - _ => { - for (i, d) in dst.iter_mut().enumerate() { - for (c, v) in px.iter_mut().enumerate() { - *v = read_sample(&t.data, fmt.sample, base + i * n + c); - } - *d = to_rgba(&fmt, px); - } - } - } + decode_tile_row_rgba(&fmt, t, base, dst, &mut row); } } } @@ -721,7 +737,9 @@ impl Surface { /// Fallible counterpart to [`Surface::take_tiles`]. Reserves the complete result and any /// default tile before removing a tile, so a reservation failure leaves the surface intact. pub fn try_take_tiles(&mut self, r: Rect) -> Result)>, AllocationError> { - let count = r.tiles().count(); + // Counted in u64 and checked before narrowing: on 32-bit targets the grid of a sparse + // surface's bounds can pass `usize` (and visiting it to count it takes just as long). + let count = usize::try_from(r.tile_count()).map_err(|_| AllocationError::SizeOverflow)?; let bytes = count.checked_mul(std::mem::size_of::<(TileCoord, Arc)>()).ok_or(AllocationError::SizeOverflow)?; let mut out = Vec::new(); out.try_reserve_exact(count).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; @@ -879,6 +897,10 @@ impl Surface { // One tile row of the default pixel, to compare a finished tile with a row at a time. let default_row = self.default_pixel.repeat(TILE_SIZE.unsigned_abs() as usize); + // Target tiles rebuilt from the same source tiles at the same offset are the same tile: + // a bundle can point thousands of tile references at one payload, and a move would + // otherwise copy it into a separate buffer for each. + let built: std::sync::Mutex, Option>>> = Default::default(); let build_target = |dc: TileCoord| -> Option<(TileCoord, Arc)> { let dr = dc.rect(); let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg()); @@ -893,6 +915,14 @@ impl Surface { // 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)); } + // The identities of the source tiles under a covered window (the offset is the same + // for every target of this call). + let memo_key: Option> = covered.then(|| sr.tiles().map(|sc| self.tiles.get(&sc).map_or(0, |t| Arc::as_ptr(t) as usize)).collect()); + if let Some(k) = &memo_key + && let Some(hit) = built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(k) + { + 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 (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); @@ -973,7 +1003,14 @@ 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. - tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| (dc, Arc::new(tile))) + let finished = tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| Arc::new(tile)); + // 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 { + Some(k) => built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(k).or_insert(finished).clone(), + None => finished, + }; + finished.map(|t| (dc, t)) }; #[cfg(not(target_arch = "wasm32"))] { @@ -1087,7 +1124,8 @@ impl Surface { /// Composite-friendly RGBA read (converts from the surface's model). pub fn rgba(&self, x: i32, y: i32) -> [f32; 4] { - let p = self.pixel(x, y); + let mut p = [0.0f32; 5]; + self.read_pixel(x, y, &mut p); to_rgba(&self.format, &p) } } @@ -1657,6 +1695,31 @@ mod tests { } } + #[test] + fn rgba_reads_match_individual_pixels_bitwise_in_every_model_and_depth() { + // Across tile edges and a missing tile; float samples include -0, out-of-range and NaN. + let rect = Rect::new(-3, -1, 300, 2); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Lab, ColorMode::Cmyk] { + for depth in SampleType::ALL { + for alpha in [false, true] { + let fmt = PixelFormat::new(mode, depth, alpha); + let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + let values = [-0.0, 0.1, 0.5, 1.0, 1.5, -0.25, f32::NAN, 0.75]; + for (i, x) in (-2..260).enumerate() { + let px: Vec = (0..fmt.channels()).map(|c| values[(i + c * 3) % values.len()]).collect(); + s.write_pixel(x, 0, &px); + } + let mut out = vec![[0.0; 4]; (rect.width() * rect.height()) as usize]; + s.read_rgba_into(rect, &mut out); + for (i, actual) in out.iter().enumerate() { + let expected = s.rgba(rect.x0 + (i % rect.width() as usize) as i32, rect.y0 + (i / rect.width() as usize) as i32); + assert_eq!(actual.map(f32::to_bits), expected.map(f32::to_bits), "{fmt:?}, pixel={i}"); + } + } + } + } + } + #[test] fn tile_content_bounds_scans_against_the_given_pixel() { let mut s = Surface::new(PixelFormat::GRAY8); @@ -1799,6 +1862,37 @@ mod tests { let _ = s.translated(i32::MIN + 1, 3, all); } + /// Many references to one payload, moved off the tile grid: the rebuilt tiles that read the + /// same source tiles at the same offset are one tile, not a copy each. + #[test] + fn translated_shares_tiles_rebuilt_from_shared_sources() { + let fmt = PixelFormat::RGBA8; + let mut one = Surface::new(fmt); + let r = Rect::new(0, 0, TILE_SIZE, TILE_SIZE); + let px: Vec = (0..r.width() * r.height()).flat_map(|i| [(i % 251) as f32 / 255.0, (i % 7) as f32 / 7.0, 0.5, 1.0]).collect(); + one.write_region(r, &px); + let payload = Arc::clone(one.tile(TileCoord::new(0, 0)).unwrap()); + let mut s = Surface::new(fmt); + s.put_tiles((0..16).flat_map(|ty| (0..16).map(move |tx| TileCoord::new(tx, ty))).map(|c| (c, Arc::clone(&payload)))); + let moved = s.translated(7, 5, s.content_bounds()); + let distinct: std::collections::HashSet<_> = moved.tiles().map(|(_, t)| Arc::as_ptr(t)).collect(); + // The 15 × 15 interior tiles read the same four source tiles at the same offset, so they are + // one tile, however many threads build them; only the 64 border tiles (cut by the + // content's edge) are built one by one. + assert!(moved.tile_count() == 289 && distinct.len() == 64 + 1, "{} tiles, {} distinct", moved.tile_count(), distinct.len()); + let all = Rect::new(-50, -50, 16 * TILE_SIZE + 50, 16 * TILE_SIZE + 50); + assert_eq!(moved.read_region(all), s.read_region(all.translate(-7, -5))); + } + + #[test] + fn a_surface_bounds_tile_count_is_counted_not_walked() { + let mut s = Surface::new(PixelFormat::RGBA8); + let far = Rect::new(0, 0, i32::MAX, i32::MAX); + assert_eq!(far.tile_count(), u64::from(i32::MAX as u32 / TILE_SIZE as u32 + 1).pow(2)); + // Not allocated: 2^46 entries is far past any reservation. + assert!(s.try_take_tiles(far).is_err()); + } + #[test] fn translated_stores_no_tiles_of_only_the_default_pixel() { let only_default = |t: &Surface| { @@ -1865,6 +1959,40 @@ mod tests { } } + #[test] + fn strided_row_sampling_handles_extreme_coordinates_and_steps() { + for sample in SampleType::ALL { + let mut s = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[0.25]); + for (x, value) in [(i32::MIN, 0.1), (-1, 0.3), (0, 0.4), (i32::MAX - 1, 0.6), (i32::MAX, 0.9)] { + s.write_pixel(x, 0, &[value]); + } + for y in [0, 256] { + for (x0, step, len) in [(i32::MAX, 1, 1), (i32::MIN, i32::MAX, 3), (0, i32::MAX, 2), (i32::MAX - 1, 1, 2), (i32::MAX, i32::MAX, 0)] { + let mut out = vec![0.0; len]; + s.sample_row_strided(y, x0, step, 0, &mut out); + for (i, &got) in out.iter().enumerate() { + let x = i32::try_from(i64::from(x0) + i as i64 * i64::from(step)).unwrap(); + assert_eq!(got, s.sample_channel(x, y, 0), "{sample:?} y={y} x0={x0} step={step} i={i}"); + } + } + } + } + } + + #[test] + fn strided_row_sampling_uses_default_past_coordinate_range() { + for sample in SampleType::ALL { + let mut s = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[0.25]); + s.write_pixel(i32::MAX, 0, &[0.75]); + s.write_pixel(i32::MIN, 0, &[1.0]); + for step in [1, i32::MAX] { + let mut out = [0.0; 4]; + s.sample_row_strided(0, i32::MAX, step, 0, &mut out); + assert_eq!(out, [s.sample_channel(i32::MAX, 0, 0), s.default_pixel()[0], s.default_pixel()[0], s.default_pixel()[0]]); + } + } + } + #[test] fn strided_row_sampling_matches_sample_channel() { let mut s = Surface::with_default(PixelFormat::GRAY8, &[0.25]); diff --git a/crates/raw/README.md b/crates/raw/README.md index bb4815a4fc..268e200b7f 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -25,7 +25,7 @@ Implemented only from public specifications, papers and observation of files: * ITU-T T.81 (ISO 10918-1) Annex H: lossless JPEG, process 14 ("LJ92"). * The published description of Canon's CR2 container (header, raw IFD, slice tag 0xC640). * Publicly documented maker-note / private tags (ExifTool's tag tables): Canon ModelID (0x0010), - SensorInfo (0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C) and BlackLevel (0x003D), Sony + SensorInfo (0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C), ColorSpace (0x001E) and BlackLevel (0x003D), Sony BlackLevel (0x7310), WB_RGGBLevels (0x7313), SonyRawFileType (0x7000) and SonyToneCurve (0x7010), the PanasonicRaw IFD0 tags, Olympus ImageProcessing (0x2040) and CameraSettings (0x2020) preview tags, the Fujifilm RAF header and metadata records (RawImageCropTopLeft / @@ -67,8 +67,8 @@ camera colour tables were copied. | Format | Status | |---|---| -| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG, lossy JPEG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, gray for CFA, YCbCr-coded RGB for LinearRaw; decoded by `zune-jpeg`, the stored 8-bit values then go through the same LinearizationTable / BlackLevel / WhiteLevel as every other depth) and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) | -| DNG (lossy JPEG, floating point; opcodes other than GainMap; ProfileGainTableMap) | Unsupported / not applied (reported) | +| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG, lossy JPEG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, gray for CFA, YCbCr-coded RGB for LinearRaw; decoded by `zune-jpeg`, the stored 8-bit values then go through the same LinearizationTable / BlackLevel / WhiteLevel as every other depth) and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, floating-point JPEG XL raw data (DNG 1.7 lossy LinearRaw, as Adobe DNG Converter writes it; scaled by the highest WhiteLevel), OpcodeList2 GainMap (lens shading) and MapPolynomial | +| DNG (floating point other than JPEG XL; opcodes other than OpcodeList2 GainMap and MapPolynomial; ProfileGainTableMap) | Unsupported / not applied (reported) | | CR2 | Lossless JPEG with slices, borders and as-shot white balance from the maker note, black measured on the masked border. CR2 has no CFA tag and the row phase varies by model, so it is measured from the data (the green diagonal), with a Canon model-ID table as the fallback (see `src/cr2.rs`) | | CR2 sRAW / mRAW | Unsupported | | NEF / NRW, ARW, PEF and other TIFF/EP raws | Uncompressed and lossless-JPEG (incl. Sony lossless ARW) CFA data | @@ -119,7 +119,11 @@ out). One cell in three is held out: the look is used only when it shows the sam the matrix is plausible and the held-out mean CIE76 ΔE drops to at most 85 % of the fallback's. Pass it as `DevelopOptions::camera_look`; `develop` then notes "colour fitted to the camera's embedded JPEG (mean ΔE …)". The crate cannot decode JPEG itself: `photocraft-io` decodes the -smallest preview at least 512 pixels long, fits and develops in one go. +smallest preview at least 512 pixels long, fits and develops in one go. A preview without an ICC +profile is read in the colour space the raw states (`embedded_preview_color_space`): the EXIF +ColorSpace (a Sony body set to Adobe RGB writes Uncalibrated with Interoperability "R03"), else a +Nikon maker note's ColorSpace (0x001E; Nikon writes no EXIF ColorSpace), sRGB when the file says +nothing. Camera Raw's tuned import fits at the default exposure and as-shot white balance first, then applies the user's Exposure, Temperature and Tint to that same rendering. The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 @@ -149,7 +153,10 @@ Raw rendering (quarter size, medians over the frames) the measured profile is 0. luminance with a mean CIE76 ΔE of 1.8; the look fitted to the embedded JPEG would be 0.30 stop and ΔE 6.4, as it follows the camera's picture style instead. The open-time re-develop (`photocraft-io`'s `import_raw_tuned`) and the tone curve its previews use (`tone_curve`) take the -same look as the default open, so changing a slider does not change the colour rendering. +same look as the default open, so changing a slider does not change the colour rendering. The D4's +saturation level was measured on 14-bit files and follows the sample depth (a 12-bit frame is the +14-bit one shifted right by two, so its level is a quarter of it): the three public 12-bit D4 files +develop within 0.02 stop of their 14-bit twin. ## Tools diff --git a/crates/raw/examples/inspect.rs b/crates/raw/examples/inspect.rs index ca7b136a90..4dd6e5709a 100644 --- a/crates/raw/examples/inspect.rs +++ b/crates/raw/examples/inspect.rs @@ -1,5 +1,5 @@ //! Prints the raw metadata of files: `cargo run -p photocraft-raw --example inspect -- a.cr2 b.nef` -//! (handy while adding corpus files; the oracle expectations in `tests/raw_corpus.rs` come from it). +//! (handy while adding corpus files; the oracle expectations in `tests/it/raw_corpus.rs` come from it). use std::env; fn main() { diff --git a/crates/raw/examples/rawinfo.rs b/crates/raw/examples/rawinfo.rs index b71e2393bd..6b1722b720 100644 --- a/crates/raw/examples/rawinfo.rs +++ b/crates/raw/examples/rawinfo.rs @@ -9,7 +9,7 @@ use std::time::Instant; -use photocraft_raw::{Demosaic, DevelopOptions, decode, develop_sensor, dump_structure, embedded_preview, identify}; +use photocraft_raw::{Demosaic, DevelopOptions, decode, develop_sensor, dump_structure, embedded_preview, embedded_preview_color_space, identify}; fn main() { let mut args = std::env::args().skip(1); @@ -76,7 +76,13 @@ fn main() { println!("{}", dump_structure(&bytes)); } if let Some(p) = embedded_preview(&bytes) { - println!(" preview: {}x{} JPEG, {} bytes", p.width, p.height, p.jpeg.len()); + println!( + " preview: {}x{} JPEG, {} bytes, colour space stated by the file: {:?}", + p.width, + p.height, + p.jpeg.len(), + embedded_preview_color_space(&bytes) + ); if let Some(dir) = png.as_ref().filter(|d| std::path::Path::new(d).is_dir()) { let stem = std::path::Path::new(&f).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); let _ = std::fs::write(format!("{dir}/{stem}.preview.jpg"), p.jpeg); diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index 84204c6bce..d7ae5e84d7 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -11,7 +11,9 @@ //! (2 px of optical black on the left, 50 + 8 on the right show as a purple stripe otherwise). The //! data is already black-subtracted (the darkest image pixels reach 0). Green saturates at //! 15546–15672 while red and blue reach 15687–16383, so one level of 15520 makes clipped -//! highlights white without shifting the white balance. Matrix, curve and black point copy +//! highlights white without shifting the white balance. That level is for 14-bit files: the body +//! also writes 12-bit ones, whose samples are the 14-bit values shifted right by two, so the +//! level follows the sample depth (`Profile::white_at`). Matrix, curve and black point copy //! Photoshop Camera Raw's default rendering of these files, measured black-box: 29 NEFs opened //! in Photoshop 25.4 against our own linear development of the same data (8 × 8 means, pixels //! clipped in neither). The matrix is our first ForwardMatrix (fitted to the camera's embedded @@ -92,7 +94,10 @@ struct Profile { size: (usize, usize), /// The image area: x, y, width, height. crop: (usize, usize, usize, usize), + /// The saturation level, in codes of `white_bits`-bit samples. white: f32, + /// The sample depth `white` was measured at. + white_bits: u32, /// The black level is known to be 0 (the data is black-subtracted in camera). black_zero: bool, forward: Mat3, @@ -102,12 +107,25 @@ struct Profile { black_point: f32, } +impl Profile { + /// The saturation level for `bits`-bit samples. A body that writes several depths stores the + /// lower ones as the deepest value shifted right (a 12-bit D4 sample is the 14-bit sample + /// divided by four), so the level scales by the same power of two. At the depth the level was + /// measured at it is returned unchanged. + fn white_at(&self, bits: u32) -> f32 { + let bits = bits.clamp(1, 16); + let measured = self.white_bits.clamp(1, 16); + if bits >= measured { self.white * f32::from(1u16 << (bits - measured)) } else { self.white / f32::from(1u16 << (measured - bits)) } + } +} + const PROFILES: &[Profile] = &[Profile { make: "NIKON CORPORATION", model: "NIKON D4", size: (4992, 3292), crop: (8, 6, 4928, 3280), white: 15520.0, + white_bits: 14, black_zero: true, forward: D4_FORWARD, color: D4_COLOR, @@ -148,8 +166,9 @@ const IMAGE_AREAS: &[ImageArea] = &[ ]; /// Applies the measured profile of the camera that made `s`, when there is one, -/// and its measured image area when the profile is not measured yet. -pub(crate) fn apply(s: &mut Sensor) { +/// and its measured image area when the profile is not measured yet. `bits` is the depth of +/// the stored samples (the profile's saturation level depends on it). +pub(crate) fn apply(s: &mut Sensor, bits: u32) { let (Some(make), Some(model)) = (s.make.as_deref(), s.model.as_deref()) else { return }; let (make, model) = (make.trim(), model.trim()); if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height) && s.crop == s.active) { @@ -164,7 +183,7 @@ pub(crate) fn apply(s: &mut Sensor) { } let (x, y, w, h) = p.crop; s.crop = Rect::new(x, y, w, h); - s.white = [p.white; 3]; + s.white = [p.white_at(bits); 3]; if p.black_zero { s.warnings.retain(|w| !w.contains("black level not recorded")); } @@ -217,7 +236,7 @@ mod tests { #[test] fn a_d4_nef_gets_its_crop_levels_matrix_and_curve() { let mut s = nef("NIKON D4", 4992, 3292); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(8, 6, 4928, 3280)); assert_eq!(s.white, [15520.0; 3]); assert_eq!(s.color.calibrations.len(), 1); @@ -229,6 +248,28 @@ mod tests { assert!(s.color.balanced_to_xyz_d50([0.5, 1.0, 0.7]).is_some()); } + #[test] + fn the_d4_white_level_follows_the_sample_depth() { + // 14 bits is the depth it was measured at: unchanged. + let mut s = nef("NIKON D4", 4992, 3292); + apply(&mut s, 14); + assert_eq!(s.white, [15520.0; 3]); + // A 12-bit sample is the 14-bit one shifted right by two. + let mut s = nef("NIKON D4", 4992, 3292); + apply(&mut s, 12); + assert_eq!(s.white, [3880.0; 3]); + // Everything else of the profile is independent of the depth. + assert_eq!(s.crop, Rect::new(8, 6, 4928, 3280)); + assert_eq!(s.tone_curve.len(), D4_TONE.len()); + assert!(s.warnings.is_empty()); + // Other depths scale the same way and stay inside their own range; nonsense stays finite. + for (bits, white) in [(1, 15520.0 / 8192.0), (8, 15520.0 / 64.0), (13, 7760.0), (16, 62080.0), (0, 15520.0 / 8192.0), (200, 62080.0)] { + let mut s = nef("NIKON D4", 4992, 3292); + apply(&mut s, bits); + assert_eq!(s.white, [white; 3], "{bits} bits"); + } + } + #[test] fn d5100_image_area_respects_explicit_crops_and_exact_sensor_size() { let mut s = nef("NIKON D5100", 4992, 3280); @@ -236,7 +277,7 @@ mod tests { s.active = Rect::new(0, 0, 4984, 3280); s.crop = s.active; let wb = s.camera_wb; - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(8, 8, 4928, 3264)); assert_eq!(s.active, Rect::new(0, 0, 4984, 3280)); assert_eq!(s.camera_wb, wb); @@ -244,11 +285,11 @@ mod tests { assert_eq!(s.white, [16383.0; 3]); s.crop = Rect::new(16, 12, 4800, 3200); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(16, 12, 4800, 3200)); for (model, w, h) in [("NIKON D5100", 2496, 1640), ("NIKON D5200", 4992, 3280)] { let mut other = nef(model, w, h); - apply(&mut other); + apply(&mut other, 14); assert_eq!(other.crop, other.active); } } @@ -256,36 +297,36 @@ mod tests { #[test] fn measured_image_areas_crop_the_borders_without_touching_the_rest() { let mut s = nef("NIKON D800E", 7424, 4924); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(8, 6, 7360, 4912)); // Only the image area is measured: levels, colour and tone stay untouched. assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); assert_eq!(s.white, [16383.0; 3]); let mut s = sensor("SONY", "ILCE-7", 6048, 4024); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000)); // Another size or model of the same maker is left alone. let mut s = nef("NIKON D800E", 2496, 1646); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(0, 0, 2496, 1646)); let mut s = nef("NIKON D800", 7424, 4924); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(0, 0, 7424, 4924)); } #[test] fn d90_image_area_matches_the_camera_jpeg() { let mut s = nef("NIKON D90", 4352, 2868); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(10, 10, 4288, 2848)); // Only the image area is measured: levels, colour and tone stay untouched. assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); assert_eq!(s.white, [16383.0; 3]); // An explicit crop in the file wins over the measured area. s.crop = Rect::new(0, 0, 4288, 2848); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(0, 0, 4288, 2848)); } @@ -298,7 +339,7 @@ mod tests { calibrations: vec![Calibration { temperature: 6504.0, color_matrix: IDENTITY, forward_matrix: None, camera_calibration: IDENTITY }], ..ColorInfo::default() }; - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.color.calibrations.len(), 1); assert_eq!(s.color.calibrations[0].temperature, 6504.0, "the file's own calibration stands"); assert!(s.tone_curve.is_empty() && s.black_point == 0.0); @@ -309,7 +350,7 @@ mod tests { fn other_cameras_and_sizes_are_left_alone() { for (model, w, h) in [("NIKON D4S", 4992, 3292), ("NIKON D4", 2496, 1646), ("NIKON D800", 7424, 4924)] { let mut s = nef(model, w, h); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(0, 0, w, h), "{model} {w}x{h}"); assert!(s.tone_curve.is_empty() && s.color.calibrations.is_empty() && s.white == [16383.0; 3]); assert_eq!(s.warnings.len(), 1); diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index 8b87f14a69..fa703b2bb4 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -472,6 +472,14 @@ pub(crate) fn ahd_bands(h: usize) -> usize { pub(crate) const AHD_TABLE_BYTES: u64 = (LABF_STEPS as u64 + 2) * 4; +/// `v` limited to `lo..=hi` like `f32::clamp`, without its panic on NaN bounds (hostile black +/// levels can put NaN in the plane): same result for every non-NaN bound. +#[inline(always)] +fn clampf(v: f32, lo: f32, hi: f32) -> f32 { + let v = if v < lo { lo } else { v }; + if v > hi { hi } else { v } +} + fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32], jobs: usize) { let (w, s) = (p.w, p.stride); let d = &p.data; @@ -500,10 +508,10 @@ fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32], jobs: usize) } let (l, r) = (d[i - 1], d[i + 1]); let gh = (l + r) * 0.5 + (2.0 * c - d[i - 2] - d[i + 2]) * 0.25; - g[0][k] = gh.clamp(l.min(r), l.max(r)); + g[0][k] = clampf(gh, l.min(r), l.max(r)); let (u, dn) = (d[i - s], d[i + s]); let gv = (u + dn) * 0.5 + (2.0 * c - d[i - 2 * s] - d[i + 2 * s]) * 0.25; - g[1][k] = gv.clamp(u.min(dn), u.max(dn)); + g[1][k] = clampf(gv, u.min(dn), u.max(dn)); } } // 2. RGB candidates and their Lab, band rows 1..n-1, columns 3..s-3. @@ -600,6 +608,12 @@ mod tests { } } + #[test] + fn ahd_survives_nan_planes() { + let p = Padded::from_plane(&[f32::NAN; 64], 8, 8); + assert_eq!(demosaic(&p, [0, 1, 1, 2], Demosaic::Ahd, &IDENTITY, 1).len(), 192); + } + /// Mosaics an RGB image with the given phase. fn mosaic(rgb: &[[f32; 3]], w: usize, h: usize, phase: [u8; 4]) -> Vec { (0..w * h).map(|i| rgb[i][phase[((i / w) & 1) * 2 + ((i % w) & 1)] as usize]).collect() diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index 71a8384cd0..ca08846a85 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -279,8 +279,12 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { let x = c.x + p; let black = bl.at(x.saturating_sub(s.active.x), y.saturating_sub(s.active.y), 0, 1); let range = s.white[0] - black; - let gain = if range >= 1.0 { mult[usize::from(cfa.color(x, y)).min(2)] / range } else { 0.0 }; - (black, gain) + // A non-finite black (a BlackLevel DOUBLE beyond f32) gives 0, as + // `Scale::value` does, instead of (raw - inf) * 0 = NaN. + if !(range >= 1.0 && black.is_finite()) { + return (0.0, 0.0); + } + (black, mult[usize::from(cfa.color(x, y)).min(2)] / range) }) .collect(); let src = &s.data[y * s.width + c.x..y * s.width + c.x + w]; @@ -375,9 +379,23 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { Some(l) => GammaLut::with_tone(l.tone.as_ref()), None => GammaLut::with_curve(&s.tone_curve), }; + // The default rendering (no Dehaze, no local tone): matrix then encode, in blocks so the + // encode's clamp and square root run over whole slices. + let plain = map.is_none() && haze.is_none(); par::chunks_mut(&mut out, band * w * 3, |b, chunk| { let start = b * band * w * 3; let src = &rgb[start..start + chunk.len()]; + if plain { + let mut buf = [0.0f32; ENCODE_BLOCK]; + for (oc, sc) in chunk.chunks_mut(ENCODE_BLOCK).zip(src.chunks(ENCODE_BLOCK)) { + let n = sc.len(); + for (t, p) in buf[..n].as_chunks_mut::<3>().0.iter_mut().zip(sc.as_chunks::<3>().0) { + *t = pro(p); + } + lut.encode_into(&buf[..n], oc); + } + return; + } for (i, (o, p)) in chunk.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0).enumerate() { let mut v = linear(b * band * w + i, p); if let Some(m) = &map { @@ -476,9 +494,13 @@ fn gain_row(s: &Sensor, y: usize, x0: usize, w: usize, k: usize) -> Option, + /// `[table[i], table[i + 1] - table[i]]`: the same interpolation from one load. + pairs: Vec<[f32; 2]>, } const GAMMA_STEPS: usize = 4096; +/// Samples per block of [`GammaLut::encode_into`] (whole pixels). +const ENCODE_BLOCK: usize = 3 * 256; impl GammaLut { #[cfg(test)] @@ -494,7 +516,7 @@ impl GammaLut { tone(curve, s * s).clamp(0.0, 1.0).powf(1.0 / 1.8) * 65535.0 }) .collect(); - GammaLut { table } + Self::from_table(table) } /// The gamma-1.8 encoding after a fitted [`ToneCurve`] (`None`: none, plain gamma 1.8). @@ -508,7 +530,30 @@ impl GammaLut { } }) .collect(); - GammaLut { table } + Self::from_table(table) + } + + fn from_table(table: Vec) -> Self { + let pairs = table.windows(2).map(|p| [p[0], p[1] - p[0]]).collect(); + GammaLut { table, pairs } + } + + /// [`Self::encode`] of every sample of `v` into `out` (at most [`ENCODE_BLOCK`] samples): + /// the clamp and square root first over the whole block, then the table. + #[inline] + fn encode_into(&self, v: &[f32], out: &mut [u16]) { + let mut xs = [0.0f32; ENCODE_BLOCK]; + let n = v.len().min(ENCODE_BLOCK).min(out.len()); + for (x, &v) in xs[..n].iter_mut().zip(&v[..n]) { + let v = if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }; + *x = v.sqrt() * GAMMA_STEPS as f32; + } + for (o, &x) in out[..n].iter_mut().zip(&xs[..n]) { + let i = (x as usize).min(GAMMA_STEPS); + let f = x - i as f32; + let [a, step] = self.pairs[i]; + *o = (a + step * f + 0.5) as u16; + } } #[inline] @@ -589,6 +634,17 @@ pub(crate) fn orient(src: Vec, w: usize, h: usize, o: u16) -> (Vec, us mod tests { use super::*; + #[test] + fn block_encode_matches_per_sample_encode() { + let lut = GammaLut::with_curve(&[[0.25, 0.5], [1.0, 1.0]]); + let v: Vec = (0..2000).map(|i| (i as f32 - 300.0) / 1500.0).chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, 1.0]).collect(); + for n in [0, 1, 3, 255, ENCODE_BLOCK, v.len().min(ENCODE_BLOCK)] { + let mut out = vec![0u16; n]; + lut.encode_into(&v[..n], &mut out); + assert_eq!(out, v[..n].iter().map(|&x| lut.encode(x)).collect::>(), "{n}"); + } + } + #[test] fn hundred_megapixel_bayer_fits_without_raising_default_limits() { let mut opts = DevelopOptions::default(); diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index e8422b5419..8329a80d39 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -193,18 +193,26 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { if scale.len() == 2 && (scale[0] - scale[1]).abs() > 1e-6 { warnings.push("non-square pixels (DefaultScale) are not resampled".to_string()); } - // Opcode lists: OpcodeList2 gain maps (lens shading) are applied; everything else is reported. + // Opcode lists: OpcodeList2 gain maps (lens shading) and polynomials (the tone mapping lossy + // DNGs carry per plane, #8125) are applied; everything else is reported. let mut gain_maps = Vec::new(); + let mut polynomials = Vec::new(); for (tg, what) in [(tag::OPCODE_LIST_1, 1), (tag::OPCODE_LIST_2, 2), (tag::OPCODE_LIST_3, 3)] { let Some(bytes) = raw.get(tg).and_then(|e| t.raw(e)) else { continue }; - let (maps, skipped) = opcodes::parse(bytes); + let ops = opcodes::parse(bytes); if what == 2 { - gain_maps = maps; - } else if !maps.is_empty() { - warnings.push(format!("DNG OpcodeList{what}: GainMap is not applied")); + gain_maps = ops.gain_maps; + polynomials = ops.polynomials; + } else { + if !ops.gain_maps.is_empty() { + warnings.push(format!("DNG OpcodeList{what}: GainMap is not applied")); + } + if !ops.polynomials.is_empty() { + warnings.push(format!("DNG OpcodeList{what}: MapPolynomial is not applied")); + } } - if !skipped.is_empty() { - warnings.push(format!("DNG OpcodeList{what}: {} not applied", skipped.join(", "))); + if !ops.skipped.is_empty() { + warnings.push(format!("DNG OpcodeList{what}: {} not applied", ops.skipped.join(", "))); } } @@ -215,14 +223,18 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { } let max = ((1u32 << plane.bits) - 1) as f32; - let linearization = raw.get(tag::LINEARIZATION_TABLE).map(|e| t.uints(e)).filter(|v| !v.is_empty() && v.len() <= 65536); - let linearization: Option> = linearization.map(|v| v.into_iter().map(|x| x.min(65535) as u16).collect()); + // A LinearizationTable maps integer samples; floating-point data is linear already. + let linearization = + raw.get(tag::LINEARIZATION_TABLE).filter(|_| plane.float_scale.is_none()).map(|e| t.uints(e)).filter(|v| !v.is_empty() && v.len() <= 65536); + let mut linearization: Option> = linearization.map(|v| v.into_iter().map(|x| x.min(65535) as u16).collect()); let white_default = match &linearization { Some(l) => f32::from(l.iter().copied().max().unwrap_or(u16::MAX)), None => max, }; + // Floating-point levels are in the floats' units: scale them as the samples were. + let unit = plane.float_scale.unwrap_or(1.0); let wl = t.tag_floats(&raw, tag::WHITE_LEVEL); - let white = [0, 1, 2].map(|s| wl.get(s).or_else(|| wl.first()).map(|v| *v as f32).filter(|v| *v > 0.0).unwrap_or(white_default)); + let white = [0, 1, 2].map(|s| wl.get(s).or_else(|| wl.first()).map(|v| *v as f32 * unit).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(white_default)); let (rows, cols) = match t.tag_uints(&raw, tag::BLACK_LEVEL_REPEAT_DIM).as_slice() { [r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*r as usize, *c as usize), @@ -230,17 +242,29 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { }; let bl = t.tag_floats(&raw, tag::BLACK_LEVEL); let mut black = if bl.len() == rows * cols * samples { - BlackLevels { rows, cols, values: bl.iter().map(|v| *v as f32).collect(), delta_h: Vec::new(), delta_v: Vec::new() } + BlackLevels { rows, cols, values: bl.iter().map(|v| *v as f32 * unit).collect(), delta_h: Vec::new(), delta_v: Vec::new() } } else { - BlackLevels::uniform(bl.first().copied().unwrap_or(0.0) as f32) + BlackLevels::uniform(bl.first().copied().unwrap_or(0.0) as f32 * unit) }; let dh = t.tag_floats(&raw, tag::BLACK_LEVEL_DELTA_H); if dh.len() == active.width { - black.delta_h = dh.iter().map(|v| *v as f32).collect(); + black.delta_h = dh.iter().map(|v| *v as f32 * unit).collect(); } let dv = t.tag_floats(&raw, tag::BLACK_LEVEL_DELTA_V); if dv.len() == active.height { - black.delta_v = dv.iter().map(|v| *v as f32).collect(); + black.delta_v = dv.iter().map(|v| *v as f32 * unit).collect(); + } + + // MapPolynomial maps linear reference values: linearize the samples first (the table is then + // done with), and map them in place. + let mut data = plane.data; + if !polynomials.is_empty() { + if let Some(l) = linearization.take() { + for v in &mut data { + *v = l.get(usize::from(*v)).or_else(|| l.last()).copied().unwrap_or(*v); + } + } + opcodes::apply_polynomials(&mut data, plane.width, samples, active, &polynomials, &black, white); } let color = color_info(t, &ifd0); @@ -258,7 +282,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { width: plane.width, height: plane.height, samples, - data: plane.data, + data, cfa, linearization, black, diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 0717dbb3cd..8dc2ff064f 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -18,15 +18,18 @@ use crate::error::{RawError, Result}; /// The browser build has no JPEG XL decoder (see the module docs). #[cfg(target_arch = "wasm32")] -pub(crate) fn decode(_src: &[u8], _w: usize, _h: usize, _samples: usize, _bits: u32, _limits: &Limits) -> Result> { +pub(crate) fn decode(_src: &[u8], _w: usize, _h: usize, _samples: usize, _bits: u32, _float_scale: Option, _limits: &Limits) -> Result> { Err(RawError::unsupported("JPEG XL-compressed DNG (DNG 1.7) can't be opened in the browser version of PhotoCraft; open it in the desktop app")) } /// Decodes one segment into `w * h * samples` interleaved samples at `bits` per sample (the -/// TIFF BitsPerSample, which DNG requires the codestream's bit depth to match). +/// TIFF BitsPerSample, which DNG requires the codestream's bit depth to match). Floating-point +/// samples (SampleFormat 3) come back multiplied by `float_scale`, clipped to 0..=65535. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, limits: &Limits) -> Result> { - limits.check(w as u64, h as u64, samples as u64 * 2)?; +pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, float_scale: Option, limits: &Limits) -> Result> { + // Floating-point samples are read into an f32 buffer first: four bytes a sample, not two. + let sample_bytes = if float_scale.is_some() { 4 } else { 2 }; + limits.check(w as u64, h as u64, samples as u64 * sample_bytes)?; // jxl-oxide decodes into f32/i32 planes: allow a few times the output size, and never more // 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); @@ -50,6 +53,13 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, return Err(RawError::unsupported("rotated JPEG XL raw tile")); } let n = w.checked_mul(h).and_then(|p| p.checked_mul(samples)).ok_or_else(|| RawError::malformed("JPEG XL raw tile is too large"))?; + if let Some(scale) = float_scale { + let mut f = vec![0f32; n]; + if stream.write_to_buffer(&mut f) != n { + return Err(RawError::malformed("JPEG XL raw data is truncated")); + } + return Ok(f.into_iter().map(|v| if v.is_nan() { 0 } else { (v * scale).clamp(0.0, 65535.0).round() as u16 }).collect()); + } let mut out = vec![0u16; n]; if stream.write_to_buffer(&mut out) != n { return Err(RawError::malformed("JPEG XL raw data is truncated")); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0d1a99e796..24cd852c77 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -76,8 +76,8 @@ pub use crate::demosaic::Demosaic; pub use crate::develop::{DevelopOptions, Developed, RawInfo, WhiteBalance, develop_sensor}; pub use crate::error::RawError; pub use crate::look::{CameraLook, LookRejected, PROXY_EDGE, Reference, ToneCurve, fit_look}; -pub use crate::opcodes::GainMap; -pub use crate::preview::{Preview, PreviewColorSpace, embedded_preview, embedded_preview_color_space, embedded_previews}; +pub use crate::opcodes::{GainMap, MapPolynomial}; +pub use crate::preview::{DecodedPreview, Preview, PreviewColorSpace, embedded_jxl_preview, embedded_preview, embedded_preview_color_space, embedded_previews}; pub use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; pub use crate::tone::{Tone, retone}; diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index 4ab32df8b8..070b3757fd 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -249,7 +249,7 @@ impl<'a> Bits<'a> { } /// A difference value: Huffman-coded category SSSS then SSSS extra bits (T.81 H.1.2.2). - #[inline] + #[inline(always)] fn diff(&mut self, h: &Huffman) -> Result { if self.n < 32 { self.refill(); @@ -284,8 +284,11 @@ pub(crate) fn frame(data: &[u8]) -> Result { struct Parsed<'a> { frame: Frame, - /// Per component of the scan (in frame order): its Huffman table. + /// The scan's distinct Huffman tables... tables: Vec, + /// ...and per component of the scan (in frame order) the index of its table: components + /// sharing a table share one copy (one 48 KiB lookup pair in cache instead of several). + comp_table: Vec, predictor: u8, point_transform: u8, restart: usize, @@ -378,7 +381,9 @@ fn parse(data: &[u8]) -> Result> { if ns != f.components || seg.len() < 1 + 2 * ns + 3 { return Err(RawError::unsupported("lossless JPEG with non-interleaved scans")); } - let mut tables = Vec::with_capacity(ns); + let mut tables: Vec = Vec::with_capacity(ns); + let mut ids: Vec = Vec::with_capacity(ns); + let mut comp_table = Vec::with_capacity(ns); for c in 0..ns { let id = seg[1 + 2 * c]; if comp_ids.get(c) != Some(&id) { @@ -386,7 +391,14 @@ fn parse(data: &[u8]) -> Result> { } let td = usize::from(seg[2 + 2 * c] >> 4); let h = huff.get(td).and_then(Option::as_ref).ok_or_else(|| RawError::malformed("missing Huffman table"))?; - tables.push(h.clone()); + match ids.iter().position(|&i| i == td) { + Some(k) => comp_table.push(k), + None => { + ids.push(td); + comp_table.push(tables.len()); + tables.push(h.clone()); + } + } } let predictor = seg[1 + 2 * ns]; let point_transform = seg[3 + 2 * ns] & 0x0F; @@ -397,7 +409,7 @@ fn parse(data: &[u8]) -> Result> { return Err(RawError::malformed("lossless JPEG point transform")); } let entropy = data.get(pos + len..).unwrap_or(&[]); - return Ok(Parsed { frame: f, tables, predictor, point_transform, restart, entropy }); + return Ok(Parsed { frame: f, tables, comp_table, predictor, point_transform, restart, entropy }); } _ => {} // APPn, COM, DQT, DNL… ignored } @@ -427,6 +439,7 @@ pub(crate) fn decode(data: &[u8], max_samples: usize) -> Result<(Frame, Vec let mut out = vec![0u16; total]; let mut bits = Bits::new(p.entropy); let nc = f.components; + let tables: Vec<&Huffman> = p.comp_table.iter().map(|&k| &p.tables[k]).collect(); let initial = 1i32 << (f.precision - p.point_transform - 1); let pred = p.predictor; let mut interval_row = 0usize; @@ -447,17 +460,28 @@ pub(crate) fn decode(data: &[u8], max_samples: usize) -> Result<(Frame, Vec // Predictor 1 (or the first line of an interval): Ra after the first column. for c in 0..nc { let px = if first_line { initial } else { i32::from(prev[c]) }; - cur[c] = (px + bits.diff(&p.tables[c])?) as u16; + cur[c] = (px + bits.diff(tables[c])?) as u16; } + // Ra: the previous pixel's sample of the same component, carried in registers. if nc == 1 { - let t = &p.tables[0]; - for i in 1..row_len { - cur[i] = (i32::from(cur[i - 1]) + bits.diff(t)?) as u16; + let t = tables[0]; + let mut ra = cur[0]; + for v in &mut cur[1..] { + ra = (i32::from(ra) + bits.diff(t)?) as u16; + *v = ra; + } + } else if nc == 2 { + let (t0, t1) = (tables[0], tables[1]); + let mut ra = [cur[0], cur[1]]; + for px in cur[2..].as_chunks_mut::<2>().0 { + ra[0] = (i32::from(ra[0]) + bits.diff(t0)?) as u16; + ra[1] = (i32::from(ra[1]) + bits.diff(t1)?) as u16; + *px = ra; } } else { for i in (nc..row_len).step_by(nc) { for c in 0..nc { - cur[i + c] = (i32::from(cur[i + c - nc]) + bits.diff(&p.tables[c])?) as u16; + cur[i + c] = (i32::from(cur[i + c - nc]) + bits.diff(tables[c])?) as u16; } } } @@ -468,7 +492,7 @@ pub(crate) fn decode(data: &[u8], max_samples: usize) -> Result<(Frame, Vec continue; } for x in 0..f.width { - for c in 0..nc { + for (c, &table) in tables.iter().enumerate() { let i = x * nc + c; let px = if x == 0 { if first_line { initial } else { i32::from(prev[i]) } @@ -490,7 +514,7 @@ pub(crate) fn decode(data: &[u8], max_samples: usize) -> Result<(Frame, Vec } } }; - let d = bits.diff(&p.tables[c])?; + let d = bits.diff(table)?; cur[i] = (px + d) as u16; } } @@ -510,7 +534,7 @@ pub(crate) fn decode(data: &[u8], max_samples: usize) -> Result<(Frame, Vec #[cfg(test)] mod tests { use super::*; - use crate::testgen::lj92_encode; + use crate::testgen::{lj92_encode, lj92_encode_tables}; /// Deterministic pseudo-random samples with some structure (smooth + noise). fn samples(n: usize, precision: u8, seed: u64) -> Vec { @@ -554,6 +578,41 @@ mod tests { } } + /// Real CR2 frames declare one DHT per component. Each case gives the components + /// different tables (or a mixed sharing), so decoding with the wrong table for a + /// component, or one table for all, corrupts the output. + #[test] + fn roundtrip_with_one_huffman_table_per_component() { + let (w, h) = (13, 7); + for table_of in [&[0u8, 1][..], &[1, 0], &[0, 1, 2, 3], &[0, 1, 0, 1], &[0, 0, 1, 1], &[3, 2, 1, 0]] { + let comps = table_of.len(); + for predictor in [1u8, 4, 7] { + for restart_rows in [None, Some(2)] { + let v = samples(w * h * comps, 14, u64::from(predictor) * 7 + comps as u64); + let j = lj92_encode_tables(&v, w, h, table_of, 14, predictor, restart_rows); + let (f, out) = decode(&j, usize::MAX).unwrap(); + assert_eq!(f, Frame { precision: 14, width: w, height: h, components: comps }); + assert_eq!(out, v, "tables={table_of:?} pred={predictor} restart={restart_rows:?}"); + } + } + } + } + + #[test] + fn roundtrip_per_component_tables_with_16_bit_extremes() { + let (w, h) = (16, 9); + for table_of in [&[0u8, 1][..], &[0, 1, 0, 1]] { + let comps = table_of.len(); + // Alternating 0 / 65535 forces the 16-bit and 32768 difference categories. + let v: Vec = (0..w * h * comps).map(|i| if (i / 3) % 2 == 0 { 0 } else { 65535 }).collect(); + for (predictor, restart_rows) in [(1u8, Some(1)), (1, Some(4)), (6, Some(2)), (6, None)] { + let j = lj92_encode_tables(&v, w, h, table_of, 16, predictor, restart_rows); + let (_, out) = decode(&j, usize::MAX).unwrap(); + assert_eq!(out, v, "tables={table_of:?} pred={predictor} restart={restart_rows:?}"); + } + } + } + #[test] fn hostile_streams_error_without_panicking() { let v = samples(32 * 8, 14, 7); diff --git a/crates/raw/src/nefc.rs b/crates/raw/src/nefc.rs index db1f8b1d74..2ae312917d 100644 --- a/crates/raw/src/nefc.rs +++ b/crates/raw/src/nefc.rs @@ -69,7 +69,7 @@ pub(crate) fn read_packed(t: &Tiff, ifd: &Ifd, bits: u32, limits: &Limits) -> Re }; let src = t.bytes(*off as usize, *len as usize).ok_or_else(|| RawError::malformed("packed NEF strip lies outside the file"))?; let data = unpack_little_rows(src, w, h, bits)?; - Ok(Plane { width: w, height: h, samples: 1, bits, data }) + Ok(Plane { width: w, height: h, samples: 1, bits, data, float_scale: None }) } fn unpack_little_rows(src: &[u8], w: usize, h: usize, bits: u32) -> Result> { @@ -377,7 +377,7 @@ pub(crate) fn read_compressed(t: &Tiff, raw: &Ifd, note: Option<&(Tiff, Ifd)>, l let end = (last as usize).saturating_add(last_len as usize).min(t.data.len()); let src = t.data.get(start..end).filter(|s| !s.is_empty()).ok_or_else(|| RawError::malformed("raw data lies outside the file"))?; let data = decode(src, width, height, bits, &table)?; - Ok(Plane { width, height, samples: 1, bits, data }) + Ok(Plane { width, height, samples: 1, bits, data, float_scale: None }) } #[cfg(test)] diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 6be6d45077..9b333fd4cf 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -2,8 +2,12 @@ //! //! An opcode list is big-endian whatever the file's byte order: a count, then //! per opcode its ID, DNG version, flags (bit 0 = optional), parameter size -//! and parameters. Only GainMap (ID 9), the lens-shading correction phones -//! write in OpcodeList2, is applied; other opcodes are reported. +//! and parameters. OpcodeList2's GainMap (ID 9), the lens-shading correction +//! phones write, and MapPolynomial (ID 8), the tone mapping lossy DNGs carry +//! per plane, are applied; other opcodes are reported. + +use crate::par; +use crate::sensor::{BlackLevels, Rect}; /// One GainMap opcode: a grid of gains, bilinearly interpolated, applied to /// the pixels of an area (in active-area coordinates) at a row / column pitch. @@ -154,28 +158,120 @@ fn gain_map(p: &[u8]) -> Option { }) } -/// Parses an opcode list: the gain maps, and the names of opcodes not applied. -pub(crate) fn parse(list: &[u8]) -> (Vec, Vec) { - let mut maps = Vec::new(); - let mut skipped = Vec::new(); - let Some(count) = be32(list, 0) else { return (maps, skipped) }; +/// One MapPolynomial opcode: `out = Σ cᵢ · inⁱ` on the linear reference values (0..1, after the +/// black and white levels) of an area, in active-area coordinates, and a plane range, at a row / +/// column pitch. Input and output are clipped to 0..1. +#[derive(Debug, Clone, PartialEq)] +pub struct MapPolynomial { + pub top: usize, + pub left: usize, + pub bottom: usize, + pub right: usize, + pub plane: usize, + pub planes: usize, + pub row_pitch: usize, + pub col_pitch: usize, + /// `c₀ … c_degree` (degree at most 8). + pub coefficients: Vec, +} + +impl MapPolynomial { + /// The polynomial at `x`, both clipped to 0..1. + #[inline] + pub fn eval(&self, x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + let y = self.coefficients.iter().rev().fold(0.0, |acc, c| acc * x + c); + if y.is_finite() { y.clamp(0.0, 1.0) } else { 0.0 } + } + + /// `true` if the opcode covers active-area pixel (`ax`, `ay`) in sample `s`. + #[inline] + fn covers(&self, ax: usize, ay: usize, s: usize) -> bool { + s >= self.plane + && s - self.plane < self.planes + && ay >= self.top + && ay < self.bottom + && ax >= self.left + && ax < self.right + && (ay - self.top).is_multiple_of(self.row_pitch) + && (ax - self.left).is_multiple_of(self.col_pitch) + } +} + +fn map_polynomial(p: &[u8]) -> Option { + let u = |i: usize| be32(p, 4 * i).map(|v| v as usize); + let (top, left, bottom, right) = (u(0)?, u(1)?, u(2)?, u(3)?); + let (plane, planes, row_pitch, col_pitch, degree) = (u(4)?, u(5)?, u(6)?, u(7)?, u(8)?); + if degree > 8 || planes == 0 || row_pitch == 0 || col_pitch == 0 || bottom <= top || right <= left { + return None; + } + let coefficients = (0..=degree).map(|i| be64f(p, 36 + 8 * i)).collect::>>()?; + Some(MapPolynomial { top, left, bottom, right, plane, planes, row_pitch, col_pitch, coefficients }) +} + +/// Applies `polys` in order to `data` (`width × height` pixels of `samples` interleaved u16 +/// samples), in the sensor's own units: each covered sample is taken to its linear reference value +/// with `black` and `white`, mapped, and brought back. +pub(crate) fn apply_polynomials(data: &mut [u16], width: usize, samples: usize, active: Rect, polys: &[MapPolynomial], black: &BlackLevels, white: [f32; 3]) { + if polys.is_empty() || width == 0 || samples == 0 { + return; + } + par::chunks_mut(data, width * samples, |y, row| { + let Some(ay) = y.checked_sub(active.y).filter(|ay| *ay < active.height) else { return }; + for ax in 0..active.width { + let x = active.x + ax; + for s in 0..samples { + let Some(v) = row.get_mut(x * samples + s) else { return }; + let b = black.at(ax, ay, s, samples); + let range = white[s.min(2)] - b; + if range.is_nan() || range < 1.0 { + continue; + } + for p in polys.iter().filter(|p| p.covers(ax, ay, s)) { + let n = (f64::from(*v) - f64::from(b)) / f64::from(range); + *v = (f64::from(b) + p.eval(n) * f64::from(range)).round().clamp(0.0, 65535.0) as u16; + } + } + } + }); +} + +/// What an opcode list holds: the gain maps and polynomials, and the names of opcodes not applied. +#[derive(Debug, Default)] +pub(crate) struct Opcodes { + pub gain_maps: Vec, + pub polynomials: Vec, + pub skipped: Vec, +} + +/// The most MapPolynomial opcodes kept from one list. Real files carry one per plane; every +/// polynomial is checked for every sample, so a crafted list of thousands would hang the open. +/// Those past the cap are named as not applied. +const MAX_POLYNOMIALS: usize = 64; + +/// Parses an opcode list. +pub(crate) fn parse(list: &[u8]) -> Opcodes { + let mut out = Opcodes::default(); + let Some(count) = be32(list, 0) else { return out }; let mut at = 4usize; for _ in 0..count.min(4096) { let (Some(id), Some(size)) = (be32(list, at), be32(list, at.saturating_add(12))) else { break }; let start = at.saturating_add(16); let Some(params) = list.get(start..start.saturating_add(size as usize)) else { break }; - match (id, gain_map(params)) { - (9, Some(m)) => maps.push(m), - _ => { - let n = name(id); - if !skipped.contains(&n) { - skipped.push(n); - } + let applied = match id { + 9 => 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, + }; + if !applied { + let n = name(id); + if !out.skipped.contains(&n) { + out.skipped.push(n); } } at = at.saturating_add(16).saturating_add(size as usize); } - (maps, skipped) + out } #[cfg(test)] @@ -214,7 +310,7 @@ mod tests { #[test] fn parses_gain_maps_and_names_others() { let b = list(&[(9, gain_map_params([0, 0, 10, 10], 1, &[[1.0, 2.0], [3.0, 4.0]])), (1, vec![0; 8])]); - let (maps, skipped) = parse(&b); + let Opcodes { gain_maps: maps, skipped, .. } = parse(&b); assert_eq!(maps.len(), 1); assert_eq!(skipped, vec!["WarpRectilinear".to_string()]); let m = &maps[0]; @@ -235,7 +331,42 @@ mod tests { } let mut huge = b.clone(); huge[4 + 16 + 32..4 + 16 + 40].copy_from_slice(&[0xFF; 8]); // points - assert!(parse(&huge).0.is_empty()); + assert!(parse(&huge).gain_maps.is_empty()); + } + + fn polynomial_params(pitch: u32, degree: u32, coefficients: &[f64]) -> Vec { + let mut p = Vec::new(); + for v in [0, 0, 10, 10, 1, 2, pitch, pitch, degree] { + p.extend_from_slice(&v.to_be_bytes()); + } + for c in coefficients { + p.extend_from_slice(&c.to_be_bytes()); + } + p + } + + #[test] + fn parses_map_polynomials_and_refuses_bad_ones() { + let ops = parse(&list(&[(8, polynomial_params(1, 2, &[0.1, 0.5, 0.4]))])); + assert!(ops.skipped.is_empty()); + let p = &ops.polynomials[0]; + assert_eq!((p.plane, p.planes, p.coefficients.clone()), (1, 2, vec![0.1, 0.5, 0.4])); + assert!((p.eval(0.5) - (0.1 + 0.25 + 0.1)).abs() < 1e-12); + assert_eq!((p.eval(-1.0), p.eval(9.0)), (0.1, 1.0), "input and output clip to 0..1"); + assert!(p.covers(3, 4, 1) && p.covers(3, 4, 2) && !p.covers(3, 4, 0) && !p.covers(3, 4, 3)); + // A degree above 8, a zero pitch, or too few coefficients: not applied, and named. + for bad in [polynomial_params(1, 9, &[0.0; 10]), polynomial_params(0, 1, &[0.0, 1.0]), polynomial_params(1, 2, &[0.0, 1.0])] { + let ops = parse(&list(&[(8, bad)])); + assert!(ops.polynomials.is_empty()); + assert_eq!(ops.skipped, vec!["MapPolynomial".to_string()]); + } + } + + #[test] + fn map_polynomials_past_the_cap_are_named_not_applied() { + let ops = parse(&list(&vec![(8, polynomial_params(1, 1, &[0.0, 1.0])); MAX_POLYNOMIALS + 1])); + assert_eq!(ops.polynomials.len(), MAX_POLYNOMIALS); + assert_eq!(ops.skipped, vec!["MapPolynomial".to_string()]); } #[test] @@ -243,7 +374,7 @@ mod tests { // at_rv (direct 4-corner bilinear) must equal the old row() + at() path for a range of // sample positions, including points outside the grid that clamp. let b = list(&[(9, gain_map_params([0, 0, 10, 10], 1, &[[1.0, 2.0], [3.0, 4.5]]))]); - let (maps, _) = parse(&b); + let maps = parse(&b).gain_maps; let m = &maps[0]; for &rv in &[-1.0, 0.0, 0.17, 0.5, 0.83, 1.0, 2.0] { let row = m.row(rv, 0); diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 9f63173bf6..020539a386 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -1,5 +1,7 @@ //! Finding the camera-rendered JPEG preview embedded in a raw file. +use crate::Limits; +use crate::sensor::{JPEG_XL, JpegLayout, read_plane}; use crate::tiff::{Tiff, tag}; /// An embedded baseline-JPEG preview. @@ -101,25 +103,86 @@ pub fn embedded_previews(bytes: &[u8]) -> Vec> { found } +/// A DNG 1.7 preview compressed with JPEG XL (Compression 52546), decoded. Converters that +/// write their raw data as JPEG XL store the larger previews that way too, leaving only a small +/// thumbnail as a JPEG stream for [`embedded_previews`] (#8125). +#[derive(Debug, Clone, PartialEq)] +pub struct DecodedPreview { + pub width: u32, + pub height: u32, + /// BitsPerSample: `rgb` holds `0..=2^bits - 1`. + pub bits: u32, + /// Interleaved RGB, `width * height * 3` samples. + pub rgb: Vec, + /// The DNG PreviewColorSpace (tag 50970); `None` when it's unknown or grey. + pub color_space: Option, +} + +/// DNG PreviewColorSpace (DNG Specification 1.7). +const PREVIEW_COLOR_SPACE: u16 = 50970; + +/// The largest JPEG XL-compressed RGB preview of a DNG that decodes within `limits`: larger ones +/// that don't (over the limits, or damaged) give way to the next. `None` when none decodes +/// (always in the browser build, which has no JPEG XL decoder). +pub fn embedded_jxl_preview(bytes: &[u8], limits: &Limits) -> Option { + let t = Tiff::new(bytes)?; + let ifd0 = t.ifd_at(t.first_ifd, 0)?; + if !ifd0.has(tag::DNG_VERSION) { + return None; + } + let area = |i: &crate::tiff::Ifd| u64::from(t.tag_uint(i, tag::IMAGE_WIDTH).unwrap_or(0)) * u64::from(t.tag_uint(i, tag::IMAGE_LENGTH).unwrap_or(0)); + let mut previews: Vec<_> = t + .all_ifds() + .into_iter() + .filter(|i| t.tag_uint(i, tag::NEW_SUBFILE_TYPE) == Some(1)) + .filter(|i| t.tag_uint(i, tag::COMPRESSION) == Some(JPEG_XL) && t.tag_uint(i, tag::SAMPLES_PER_PIXEL) == Some(3)) + // A rendered RGB (or YCbCr) picture, not a reduced-resolution LinearRaw or CFA image. + .filter(|i| matches!(t.tag_uint(i, tag::PHOTOMETRIC), Some(2 | 6))) + .collect(); + previews.sort_by_key(|i| std::cmp::Reverse(area(i))); + let (ifd, plane) = previews.into_iter().find_map(|i| read_plane(&t, &i, limits, JpegLayout::Flat).ok().map(|p| (i, p)))?; + let color_space = match t.tag_uint(&ifd, PREVIEW_COLOR_SPACE).or_else(|| t.tag_uint(&ifd0, PREVIEW_COLOR_SPACE)) { + Some(2) => Some(PreviewColorSpace::Srgb), + Some(3) => Some(PreviewColorSpace::AdobeRgb), + Some(4) => Some(PreviewColorSpace::ProPhoto), + _ => None, + }; + Some(DecodedPreview { width: u32::try_from(plane.width).ok()?, height: u32::try_from(plane.height).ok()?, bits: plane.bits, rgb: plane.data, color_space }) +} + /// The colour space a raw file states for its embedded previews, which carry no ICC profile of their own. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PreviewColorSpace { Srgb, AdobeRgb, + /// ProPhoto RGB, which a DNG can state for its previews (PreviewColorSpace 4). + ProPhoto, } -/// The colour space the raw's EXIF gives for the camera's JPEG rendering, for a preview that declares none itself: -/// ColorSpace (0xA001) 1 = sRGB, or Uncalibrated (0xFFFF) with the Interoperability IFD's InteropIndex "R03", the -/// DCF option file for Adobe RGB (as a Sony body set to Adobe RGB writes; Exif and DCF specifications). `None` when -/// the file doesn't say, and for a DNG, whose EXIF describes the original capture while its previews are the -/// converter's. A preview's own ICC profile takes precedence over this. +/// Nikon maker note tag ColorSpace: 1 = sRGB, 2 = Adobe RGB (4 = BT.2100, which is not a preview colour space we +/// read), from ExifTool's public Nikon tag documentation. +const NIKON_COLOR_SPACE: u16 = 0x001E; + +/// The colour space the raw states for the camera's JPEG rendering, for a preview that declares none itself: +/// * the EXIF ColorSpace (0xA001) 1 = sRGB, or Uncalibrated (0xFFFF) with the Interoperability IFD's InteropIndex +/// "R03", the DCF option file for Adobe RGB (as a Sony body set to Adobe RGB writes; Exif and DCF specifications); +/// * else, where the EXIF doesn't decide, a Nikon maker note's ColorSpace (0x001E): Nikon bodies write no EXIF +/// ColorSpace, and say Adobe RGB only here. +/// +/// `None` when the file doesn't say, and for a DNG, whose EXIF describes the original capture while its previews +/// are the converter's. A preview's own ICC profile takes precedence over this. pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { let t = Tiff::new(bytes)?; let ifd0 = t.ifd_at(t.first_ifd, 0)?; if ifd0.has(tag::DNG_VERSION) { return None; } - let exif = t.ifd_at(t.tag_uint(&ifd0, tag::EXIF_IFD)? as usize, 0)?; + exif_color_space(&t, &ifd0).or_else(|| nikon_color_space(&t)) +} + +/// The colour space the EXIF IFD gives (see [`embedded_preview_color_space`]). +fn exif_color_space(t: &Tiff<'_>, ifd0: &crate::tiff::Ifd) -> Option { + let exif = t.ifd_at(t.tag_uint(ifd0, tag::EXIF_IFD)? as usize, 0)?; match t.tag_uint(&exif, tag::COLOR_SPACE)? { 1 => Some(PreviewColorSpace::Srgb), 0xFFFF => { @@ -130,6 +193,16 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { } } +/// The colour space a Nikon maker note gives. +fn nikon_color_space(t: &Tiff<'_>) -> Option { + let (note, ifd) = crate::tiffep::nikon_maker_note(t, &t.all_ifds())?; + match note.tag_uint(&ifd, NIKON_COLOR_SPACE)? { + 1 => Some(PreviewColorSpace::Srgb), + 2 => Some(PreviewColorSpace::AdobeRgb), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -168,4 +241,95 @@ mod tests { assert_eq!(embedded_preview_color_space(junk), None); } } + + /// The maker note's embedded TIFF stream (`ifd` entries, in the given byte order) behind `header`. + fn maker_note(header: &[u8], tags: Vec<(u16, Val)>, big_endian: bool) -> Vec { + let mut note = TiffBuilder { big_endian, ..Default::default() }; + let ifd = note.ifd(tags); + note.chain = vec![ifd]; + let mut maker = header.to_vec(); + maker.extend(note.build()); + maker + } + + /// A raw whose EXIF IFD holds the entries `exif` makes (it may add directories to the builder) and a maker note. + fn raw_with_note(maker: Vec, exif: impl FnOnce(&mut TiffBuilder) -> Vec<(u16, Val)>, dng: bool) -> Vec { + let mut t = TiffBuilder::default(); + let mut exif = exif(&mut t); + exif.push((tag::MAKER_NOTE, Val::Undefined(maker))); + let exif = t.ifd(exif); + let mut ifd0 = vec![(tag::MAKE, Val::Ascii("NIKON CORPORATION".into())), (tag::EXIF_IFD, Val::Ifds(vec![exif]))]; + if dng { + ifd0.push((tag::DNG_VERSION, Val::Byte(vec![1, 6, 0, 0]))); + } + t.chain = vec![t.ifd(ifd0)]; + t.build() + } + + const NIKON: &[u8] = b"Nikon\0\x02\x10\0\0"; + + /// A Nikon raw whose maker note has ColorSpace `color_space` (when given) and whose EXIF IFD holds `exif`. + fn nikon_raw(color_space: Option, exif: impl FnOnce(&mut TiffBuilder) -> Vec<(u16, Val)>, big_endian: bool) -> Vec { + let tags = color_space.map(|cs| (NIKON_COLOR_SPACE, Val::Short(vec![cs]))).into_iter().chain([(0x0002, Val::Ascii("200".into()))]).collect(); + raw_with_note(maker_note(NIKON, tags, big_endian), exif, false) + } + + /// EXIF entries for a preview in Adobe RGB the way a Sony body writes it: Uncalibrated, Interoperability "R03". + fn exif_r03(t: &mut TiffBuilder) -> Vec<(u16, Val)> { + let interop = t.ifd(vec![(tag::INTEROP_INDEX, Val::Ascii("R03".into()))]); + vec![(tag::COLOR_SPACE, Val::Short(vec![0xFFFF])), (tag::INTEROP_IFD, Val::Ifds(vec![interop]))] + } + + /// Nikon bodies write no EXIF ColorSpace; the maker note's ColorSpace (1 sRGB, 2 Adobe RGB) is + /// what says their JPEG previews are Adobe RGB. + #[test] + fn nikon_maker_note_colour_space_of_the_previews() { + for big_endian in [false, true] { + assert_eq!(embedded_preview_color_space(&nikon_raw(Some(2), |_| vec![], big_endian)), Some(PreviewColorSpace::AdobeRgb), "be {big_endian}"); + assert_eq!(embedded_preview_color_space(&nikon_raw(Some(1), |_| vec![], big_endian)), Some(PreviewColorSpace::Srgb), "be {big_endian}"); + } + // BT.2100 (4), unknown values, a note without the tag + for cs in [Some(4), Some(0), Some(3), Some(0xFFFF), None] { + assert_eq!(embedded_preview_color_space(&nikon_raw(cs, |_| vec![], false)), None, "{cs:?}"); + } + } + + /// The EXIF IFD, when it states a colour space, comes before the maker note; when it does not decide, the + /// maker note does. + #[test] + fn exif_colour_space_comes_before_the_nikon_maker_note() { + // EXIF says sRGB, the note Adobe RGB: EXIF wins. + let raw = nikon_raw(Some(2), |_| vec![(tag::COLOR_SPACE, Val::Short(vec![1]))], false); + assert_eq!(embedded_preview_color_space(&raw), Some(PreviewColorSpace::Srgb)); + // EXIF says Adobe RGB (R03), the note sRGB: EXIF wins. + assert_eq!(embedded_preview_color_space(&nikon_raw(Some(1), exif_r03, false)), Some(PreviewColorSpace::AdobeRgb)); + // EXIF says Uncalibrated without R03, which decides nothing: the note does. + let raw = nikon_raw(Some(2), |_| vec![(tag::COLOR_SPACE, Val::Short(vec![0xFFFF]))], false); + assert_eq!(embedded_preview_color_space(&raw), Some(PreviewColorSpace::AdobeRgb)); + } + + /// Only a Nikon maker note is read, and not for a DNG, whose previews are the converter's. + #[test] + fn only_a_nikon_maker_note_of_a_non_dng_is_read() { + let tags = || vec![(NIKON_COLOR_SPACE, Val::Short(vec![2]))]; + assert_eq!(embedded_preview_color_space(&raw_with_note(maker_note(NIKON, tags(), false), |_| vec![], false)), Some(PreviewColorSpace::AdobeRgb)); + assert_eq!(embedded_preview_color_space(&raw_with_note(maker_note(NIKON, tags(), false), |_| vec![], true)), None); + for header in [&b"OLYMPUS\0II\x03\0"[..], b"SONY DSC \0\0\0", b"Nikon", b""] { + assert_eq!(embedded_preview_color_space(&raw_with_note(maker_note(header, tags(), false), |_| vec![], false)), None, "{header:?}"); + } + } + + /// A maker note that is cut short or points outside the file states nothing, and never panics. + #[test] + fn a_damaged_nikon_maker_note_states_nothing() { + let good = maker_note(NIKON, vec![(NIKON_COLOR_SPACE, Val::Short(vec![2]))], false); + assert_eq!(embedded_preview_color_space(&raw_with_note(good.clone(), |_| vec![], false)), Some(PreviewColorSpace::AdobeRgb)); + for len in 0..good.len() { + let _ = embedded_preview_color_space(&raw_with_note(good[..len].to_vec(), |_| vec![], false)); + } + // the embedded TIFF's first-IFD offset (bytes 14..18 of the note) points outside the file + let mut wild = good.clone(); + wild[14..18].copy_from_slice(&[0xFF; 4]); + assert_eq!(embedded_preview_color_space(&raw_with_note(wild, |_| vec![], false)), None); + } } diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index cab5ce2a05..677361aae5 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -189,6 +189,9 @@ pub(crate) struct Plane { pub samples: usize, pub bits: u32, pub data: Vec, + /// For floating-point samples, the factor they were multiplied by (their white level becomes + /// 65535, `bits` is then 16): the file's black and white levels are scaled by it too. + pub float_scale: Option, } pub(crate) struct Segment { @@ -212,7 +215,7 @@ pub(crate) enum JpegLayout { } /// DNG 1.7 JPEG XL compression. -const JPEG_XL: u32 = 52546; +pub(crate) const JPEG_XL: u32 = 52546; /// Inflates one zlib tile into at most `need` bytes. The stream must run to its end: a truncated /// stream, or one that holds more than `need` bytes, is an error rather than a silent cut-off. @@ -237,9 +240,22 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou if !(1..=4).contains(&samples) { return Err(RawError::unsupported(format!("{samples} samples per pixel"))); } - if t.tag_uint(ifd, tag::SAMPLE_FORMAT) == Some(3) { - return Err(RawError::unsupported("floating-point raw data")); + // Floating-point data is read when it is JPEG XL-compressed, as converters write lossy + // LinearRaw (DNG 1.7, #8125): 16, 24 or 32-bit floats, read as 16-bit samples. + let float = t.tag_uint(ifd, tag::SAMPLE_FORMAT) == Some(3); + if float && compression != JPEG_XL { + return Err(RawError::unsupported("floating-point raw data that isn't JPEG XL-compressed")); } + if float && !matches!(bits, 16 | 24 | 32) { + return Err(RawError::unsupported(format!("{bits}-bit floating-point raw samples"))); + } + let bits = if float { 16 } else { bits }; + // The floats' highest white level (default 1.0; DNG 1.7 WhiteLevel has one per sample plane) + // becomes 65535, so no plane is clipped below its own white level. + let float_scale = float.then(|| { + let white = t.tag_floats(ifd, tag::WHITE_LEVEL).iter().map(|v| *v as f32).filter(|v| v.is_finite() && *v > 0.0).fold(0.0f32, f32::max); + 65535.0 / if white > 0.0 { white } else { 1.0 } + }); if !(1..=16).contains(&bits) { return Err(RawError::unsupported(format!("{bits}-bit raw samples"))); } @@ -259,15 +275,8 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou // pixels. Each tile is charged individually below, but file-controlled padding and repeated // source ranges can make the *cumulative* retained samples far exceed both the input and the // useful image. Reject that up front: the sum of padded tile samples (two bytes each) must - // fit the allocation budget. - let mut total_tile_samples: u64 = 0; - for s in &segments { - let tile = (s.w as u64).saturating_mul(s.h as u64).saturating_mul(samples as u64); - total_tile_samples = total_tile_samples.saturating_add(tile); - } - if total_tile_samples.saturating_mul(2) > limits.max_alloc { - return Err(RawError::LimitExceeded(format!("{} bytes of retained raw tiles exceed {}", total_tile_samples.saturating_mul(2), limits.max_alloc))); - } + // fit the allocation budget (checked per segment, #4959). + 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| { @@ -293,7 +302,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou // The TIFF predictor runs after unpacking, per row, per sample plane. match t.tag_uint(ifd, tag::PREDICTOR).unwrap_or(1) { 1 => Ok(v), - 2 => Ok(undelta(&v, samples, s.w * samples, rows, bits)), + 2 => Ok(undelta(v, samples, s.w * samples, rows, bits)), 3 => Err(RawError::unsupported("floating-point predictor (compression 8, predictor 3)")), p => Err(RawError::malformed(format!("unknown predictor {p}"))), } @@ -301,39 +310,30 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou // A JPEG XL segment codes only the rows inside the image (strips) or the whole tile. JPEG_XL => { let coded_rows = if tiled { s.h } else { rows }; - let mut v = jxl::decode(src, s.w, coded_rows, samples, bits, limits)?; + let mut v = jxl::decode(src, s.w, coded_rows, samples, bits, float_scale, limits)?; v.resize(s.w * s.h * samples, 0); Ok(v) } 34892 => lossy_jpeg(src, s, rows, samples, bits, tiled, limits), - // Compression 7 (lossless JPEG): the frame the entropy stream declares is capped - // by the budget, not only by the segment, and the `Quads` scatter buffer below - // comes out of the same budget. `max_alloc` counts bytes, a sample is two. - _ => { - let need = s.w * rows * samples; - let scatter = if layout == JpegLayout::Quads && samples == 1 { s.w.saturating_mul(s.h).saturating_mul(2) as u64 } else { 0 }; - let budget = usize::try_from(limits.max_alloc.saturating_sub(scatter) / 2).unwrap_or(usize::MAX); - let geom = s.w.saturating_mul(s.h).saturating_mul(samples).saturating_mul(4); - let (f, v) = ljpeg::decode(src, geom.min(budget))?; - if v.len() < need { - return Err(RawError::malformed("lossless JPEG tile is smaller than its tile")); - } - if layout == JpegLayout::Quads && samples == 1 && f.components == 4 && f.width * 2 == s.w && f.height * 2 == s.h { - let mut tile = vec![0u16; s.w * s.h]; - for (i, q) in v.as_chunks::<4>().0.iter().enumerate() { - let at = 2 * (i / f.width) * s.w + 2 * (i % f.width); - tile[at] = q[0]; - tile[at + 1] = q[1]; - tile[at + s.w] = q[2]; - tile[at + s.w + 1] = q[3]; - } - return Ok(tile); - } - Ok(v) - } + // Compression 7 (lossless JPEG). + _ => lossless_jpeg(src, s, rows, samples, layout, limits), } }); - let mut data = vec![0u16; width * height * samples]; + // 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 taken = if whole { + let mut d = decoded.next().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")); + } + d.truncate(width * height * samples); + Some(d) + } else { + None + }; + let mut data = taken.unwrap_or_else(|| vec![0u16; width * height * samples]); for (s, d) in segments.iter().zip(decoded) { let d = d?; let rows = s.h.min(height - s.y); @@ -344,7 +344,30 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou data[at..at + cols * samples].copy_from_slice(src); } } - Ok(Plane { width, height, samples, bits, data }) + Ok(Plane { width, height, samples, bits, data, float_scale }) +} + +/// Bytes of decoded samples the segments hold at once. +/// +/// Each segment is charged against `limits` on its own just before it decodes, but every decoded +/// tile is retained until the scatter at the end of [`read_plane`], so what is alive at once is +/// their sum (plus the output plane the scatter fills), not the largest of them. A segment's geometry is declared independently of the image, so a +/// file can carry many segments that are each admissible and together are not (#4959). For a +/// well-formed file the segments tile the image, so their sum is about its own size, which +/// `limits.check` has already bounded. +fn retained_segment_bytes(segments: &[Segment], samples: usize, limits: &Limits) -> Result { + let mut total: u64 = 0; + for s in segments { + let bytes = (s.w as u64) + .checked_mul(s.h as u64) + .and_then(|px| px.checked_mul(samples as u64 * 2)) + .ok_or_else(|| RawError::LimitExceeded("a raw segment is too large to decode".into()))?; + total = total.saturating_add(bytes); + if total > limits.max_alloc { + return Err(RawError::LimitExceeded(format!("{total} bytes of decoded segments exceed {}", limits.max_alloc))); + } + } + Ok(total) } pub(crate) fn segments(t: &Tiff, ifd: &Ifd, width: usize, height: usize) -> Result> { @@ -390,6 +413,37 @@ pub(crate) fn segments(t: &Tiff, ifd: &Ifd, width: usize, height: usize) -> Resu Ok(out) } +/// Decodes one compression-7 (lossless JPEG) segment to the segment's own samples. +/// +/// The frame the entropy stream declares is capped by the budget, not only by the segment, and +/// the `Quads` scatter buffer comes out of the same budget. `max_alloc` counts bytes, a sample is two. +fn lossless_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, layout: JpegLayout, limits: &Limits) -> Result> { + let need = s.w * rows * samples; + let scatter = if layout == JpegLayout::Quads && samples == 1 { s.w.saturating_mul(s.h).saturating_mul(2) as u64 } else { 0 }; + let budget = usize::try_from(limits.max_alloc.saturating_sub(scatter) / 2).unwrap_or(usize::MAX); + let geom = s.w.saturating_mul(s.h).saturating_mul(samples).saturating_mul(4); + let (f, mut v) = ljpeg::decode(src, geom.min(budget))?; + if v.len() < need { + return Err(RawError::malformed("lossless JPEG tile is smaller than its tile")); + } + if layout == JpegLayout::Quads && samples == 1 && f.components == 4 && f.width * 2 == s.w && f.height * 2 == s.h { + let mut tile = vec![0u16; s.w * s.h]; + for (i, q) in v.as_chunks::<4>().0.iter().enumerate() { + let at = 2 * (i / f.width) * s.w + 2 * (i % f.width); + tile[at] = q[0]; + tile[at + 1] = q[1]; + tile[at + s.w] = q[2]; + tile[at + s.w + 1] = q[3]; + } + return Ok(tile); + } + // The frame may declare up to four times the segment's samples; the scatter reads only the + // segment's own rows, and the frame is retained until then, so keep just those (#4959). + v.truncate(need); + v.shrink_to_fit(); + Ok(v) +} + /// Decodes one segment of a lossy DNG (Adobe DNG compression 34892): a complete baseline-DCT /// JPEG stream (ITU-T T.81), 8 bits per sample, as DNG 1.4 defines it. A CFA segment is a /// one-component (gray) JPEG; a 3-sample LinearRaw segment is a three-component one in YCbCr, @@ -440,9 +494,8 @@ fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, t /// 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(v: &[u16], plane: usize, row_samples: usize, rows: usize, bits: u32) -> Vec { +fn undelta(mut out: Vec, plane: usize, row_samples: usize, rows: usize, bits: u32) -> Vec { let mask: u16 = if bits <= 8 { 0xFF } else { 0xFFFF }; - let mut out = v.to_vec(); for row in out.chunks_mut(row_samples.max(1)).take(rows) { for i in plane..row.len() { if let (Some(&prev), Some(&cur)) = (row.get(i - plane), row.get(i)) { @@ -471,6 +524,18 @@ fn unpack(src: &[u8], w: usize, rows: usize, samples: usize, bits: u32, le: bool if src.len() < row_bytes * rows { return Err(truncated()); } + // The common packings, a whole group of samples per fixed run of bytes (MSB first). + let per_row = w * samples; + match bits { + 12 | 14 | 10 => { + let mut out = vec![0u16; n]; + for (orow, row) in out.chunks_exact_mut(per_row.max(1)).zip(src.chunks_exact(row_bytes.max(1))) { + unpack_row_msb(row, orow, bits); + } + return Ok(out); + } + _ => {} + } let mut out = Vec::with_capacity(n); for r in 0..rows { let row = &src[r * row_bytes..(r + 1) * row_bytes]; @@ -489,10 +554,143 @@ fn unpack(src: &[u8], w: usize, rows: usize, samples: usize, bits: u32, le: bool Ok(out) } +/// One MSB-first bit-packed row of 10, 12 or 14-bit samples (`row` holds at least +/// `out.len() * bits / 8` bytes, rounded up; missing bytes read as 0, as [`unpack`] does). +fn unpack_row_msb(row: &[u8], out: &mut [u16], bits: u32) { + let mask = (1u64 << bits) - 1; + // Samples per group and bytes per group: 4 × 10 = 40, 2 × 12 = 24, 4 × 14 = 56 bits. + let (g, gb) = match bits { + 10 => (4, 5), + 12 => (2, 3), + _ => (4, 7), + }; + let groups = out.len() / g; + for (o, b) in out[..groups * g].chunks_exact_mut(g).zip(row.chunks_exact(gb)) { + let mut acc = 0u64; + for &x in b { + acc = (acc << 8) | u64::from(x); + } + for (k, v) in o.iter_mut().enumerate() { + *v = ((acc >> ((g - 1 - k) as u32 * bits)) & mask) as u16; + } + } + // The tail (fewer than a group): the general reader. + let mut acc: u32 = 0; + let mut have = 0u32; + let mut it = row.iter().skip(groups * gb); + for v in &mut out[groups * g..] { + while have < bits { + acc = (acc << 8) | u32::from(*it.next().unwrap_or(&0)); + have += 8; + } + have -= bits; + *v = ((acc >> have) & ((1 << bits) - 1)) as u16; + } +} + #[cfg(test)] mod tests { use super::*; + /// The grouped 10/12/14-bit readers equal the plain bit reader on every row width. + #[test] + fn grouped_unpack_matches_the_bit_reader() { + let plain = |src: &[u8], w: usize, rows: usize, bits: u32| { + let row_bytes = (w * bits as usize).div_ceil(8); + let mut out = Vec::new(); + for r in 0..rows { + let row = &src[r * row_bytes..(r + 1) * row_bytes]; + let (mut acc, mut have, mut it) = (0u32, 0u32, row.iter()); + for _ in 0..w { + while have < bits { + acc = (acc << 8) | u32::from(*it.next().unwrap_or(&0)); + have += 8; + } + have -= bits; + out.push(((acc >> have) & ((1 << bits) - 1)) as u16); + } + } + out + }; + let mut s = 7u64; + for bits in [10u32, 12, 14] { + for samples in [1usize, 3] { + // Widths whose packed rows are shorter than 16-bit containers (else `unpack` reads containers). + for w in [4usize, 5, 7, 8, 13, 64, 67] { + let rows = 3; + let src: Vec = (0..(w * samples * bits as usize).div_ceil(8) * rows) + .map(|_| { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (s >> 40) as u8 + }) + .collect(); + assert_eq!( + unpack(&src, w, rows, samples, bits, true).unwrap(), + plain(&src, w * samples, rows, bits), + "{bits} bits, {samples} samples, {w} wide" + ); + } + } + } + } + + /// #4959: a decoded segment is retained until the scatter, so the segments are charged + /// together. Each of these is far inside the budget on its own; their sum is not. + #[test] + fn segments_are_charged_together_rather_than_one_at_a_time() { + let limits = Limits { max_alloc: 8 << 20, ..Limits::default() }; + // 1024 x 512 samples, two bytes each: one mebibyte a tile. + let tile = |i: usize| Segment { x: 0, y: i * 512, w: 1024, h: 512, offset: 0, len: 0 }; + let eight: Vec = (0..8).map(tile).collect(); + assert_eq!(retained_segment_bytes(&eight, 1, &limits).unwrap(), 8 << 20, "the budget is spent exactly"); + + let nine: Vec = (0..9).map(tile).collect(); + assert!(matches!(retained_segment_bytes(&nine, 1, &limits), Err(RawError::LimitExceeded(_))), "one tile over"); + assert!(retained_segment_bytes(&eight, 3, &limits).is_err(), "more samples a pixel cost proportionally more"); + + // A segment whose own geometry overflows is refused rather than wrapping to a small sum. + let huge = [Segment { x: 0, y: 0, w: usize::MAX, h: usize::MAX, offset: 0, len: 0 }]; + assert!(matches!(retained_segment_bytes(&huge, 1, &limits), Err(RawError::LimitExceeded(_)))); + + // A normal tiled frame is unaffected: 6000 x 4000 in 512-pixel tiles, padding included. + let normal: Vec = (0..12 * 8).map(|i| Segment { x: (i % 12) * 512, y: (i / 12) * 512, w: 512, h: 512, offset: 0, len: 0 }).collect(); + assert!(retained_segment_bytes(&normal, 1, &Limits::default()).is_ok()); + } + + /// #4959 end to end: the sum is charged in `read_plane`, not only by the helper. An 18x18 + /// CFA in four padded 16x16 lossless-JPEG tiles: the image (648 B) and each tile (512 B) fit + /// a 1500 B budget, the four retained tiles (2048 B) do not. + #[test] + fn decode_charges_the_retained_tiles_together() { + use crate::testgen::{DngSpec, DngStorage, mosaic, scene}; + let mut d = DngSpec::cfa(18, 18, mosaic(&scene(18, 18), 18, [0, 1, 1, 2], 64, 4000)); + d.bits = 12; + d.white = 4000; + d.black = vec![64]; + d.storage = DngStorage::Lj92Tiles { width: 16, height: 16 }; + let b = d.build(); + let tight = Limits { max_alloc: 1500, ..Limits::default() }; + assert!(matches!(crate::decode(&b, &tight), Err(RawError::LimitExceeded(_)))); + let exact = Limits { max_alloc: 2048, ..Limits::default() }; + assert!(crate::decode(&b, &exact).is_ok(), "the padded tiles exactly at the budget still decode"); + } + + /// #4959: a lossless-JPEG frame may declare up to four times its segment's samples. The + /// segment is charged for its own size only, so it must keep only that until the scatter. + #[test] + fn lossless_jpeg_keeps_only_the_segment() { + let frame: Vec = (0..64 * 64).map(|i| (i % 4096) as u16).collect(); + let src = crate::testgen::lj92_encode(&frame, 64, 64, 1, 12, 1, None); + let s = Segment { x: 0, y: 0, w: 32, h: 32, offset: 0, len: src.len() }; + let v = lossless_jpeg(&src, &s, 32, 1, JpegLayout::Flat, &Limits::default()).unwrap(); + assert_eq!(v.len(), 32 * 32, "only the segment's samples are retained"); + assert!(v.capacity() < 64 * 64, "the frame's spare capacity is released"); + assert_eq!(v[..32], frame[..32], "the first row is the frame's first row"); + // An edge strip keeps only the rows inside the image. + let v = lossless_jpeg(&src, &s, 20, 1, JpegLayout::Flat, &Limits::default()).unwrap(); + assert_eq!(v.len(), 32 * 20); + } + #[test] fn bayer_patterns() { for p in ["RGGB", "BGGR", "GRBG", "GBRG"] { diff --git a/crates/raw/src/sony.rs b/crates/raw/src/sony.rs index b00fd0360a..bb5dc0a86e 100644 --- a/crates/raw/src/sony.rs +++ b/crates/raw/src/sony.rs @@ -155,7 +155,7 @@ pub(crate) fn read_craw(t: &Tiff, ifd: &Ifd, limits: &Limits) -> Result { } } }); - Ok(Plane { width, height, samples: 1, bits: 14, data }) + Ok(Plane { width, height, samples: 1, bits: 14, data, float_scale: None }) } #[cfg(test)] diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 9d8b8fb94c..02ea402161 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -88,9 +88,19 @@ impl BitWriter { /// Encodes `samples` (raster order, `components` interleaved, values below /// `2^precision`) as a lossless JPEG with the given predictor (1–7) and an -/// optional restart interval in rows. +/// optional restart interval in rows. All components share one Huffman table. pub fn lj92_encode(samples: &[u16], width: usize, height: usize, components: usize, precision: u8, predictor: u8, restart_rows: Option) -> Vec { + lj92_encode_tables(samples, width, height, &vec![0; components], precision, predictor, restart_rows) +} + +/// Like [`lj92_encode`], but component `c` uses Huffman table `table_of[c]` +/// (0–3; the component count is `table_of.len()`), one DHT per table id as +/// CR2 writes them. Table `t` assigns its code lengths rotated by `t`, so the +/// tables differ even when their statistics match. +pub fn lj92_encode_tables(samples: &[u16], width: usize, height: usize, table_of: &[u8], precision: u8, predictor: u8, restart_rows: Option) -> Vec { + let components = table_of.len(); assert_eq!(samples.len(), width * height * components); + assert!(table_of.iter().all(|&t| t < 4)); let row = width * components; let interval = restart_rows.unwrap_or(usize::MAX); // Differences per sample, following T.81 H.1.2.1 prediction rules. @@ -127,31 +137,41 @@ pub fn lj92_encode(samples: &[u16], width: usize, height: usize, components: usi } } } - // Table: categories ordered by frequency get increasing code lengths. - let mut freq = [0usize; 17]; - for &d in &diffs { - freq[category(d) as usize] += 1; - } - let mut order: Vec = (0..17).collect(); - order.sort_by_key(|&c| std::cmp::Reverse(freq[c])); - let mut len_of = [0u8; 17]; - for (rank, &c) in order.iter().enumerate() { - len_of[c] = LENGTHS[rank]; - } - let mut counts = [0u8; 16]; - let mut vals: Vec = Vec::new(); - let mut code_of = [(0u32, 0u32); 17]; - let mut code = 0u32; - for len in 1..=16u8 { - for c in 0..17 { - if len_of[c] == len { - counts[len as usize - 1] += 1; - vals.push(c as u8); - code_of[c] = (code, u32::from(len)); - code += 1; + // Tables: categories ordered by frequency get increasing code lengths. + let mut ids: Vec = table_of.to_vec(); + ids.sort_unstable(); + ids.dedup(); + let mut code_of = [[(0u32, 0u32); 17]; 4]; + let mut dhts = Vec::new(); + for &t in &ids { + let mut freq = [0usize; 17]; + for (i, &d) in diffs.iter().enumerate() { + if table_of[i % components] == t { + freq[category(d) as usize] += 1; } } - code <<= 1; + let mut order: Vec = (0..17).collect(); + order.sort_by_key(|&c| std::cmp::Reverse(freq[c])); + order.rotate_left(usize::from(t)); + let mut len_of = [0u8; 17]; + for (rank, &c) in order.iter().enumerate() { + len_of[c] = LENGTHS[rank]; + } + let mut counts = [0u8; 16]; + let mut vals: Vec = Vec::new(); + let mut code = 0u32; + for len in 1..=16u8 { + for c in 0..17 { + if len_of[c] == len { + counts[len as usize - 1] += 1; + vals.push(c as u8); + code_of[usize::from(t)][c] = (code, u32::from(len)); + code += 1; + } + } + code <<= 1; + } + dhts.push((t, counts, vals)); } let mut out = vec![0xFF, 0xD8]; // SOF3 @@ -164,12 +184,14 @@ pub fn lj92_encode(samples: &[u16], width: usize, height: usize, components: usi for c in 0..components { out.extend_from_slice(&[c as u8 + 1, 0x11, 0]); } - // DHT (one table, id 0) - out.extend_from_slice(&[0xFF, 0xC4]); - out.extend_from_slice(&((2 + 17 + vals.len()) as u16).to_be_bytes()); - out.push(0x00); - out.extend_from_slice(&counts); - out.extend_from_slice(&vals); + // DHT, one per table id + for (t, counts, vals) in &dhts { + out.extend_from_slice(&[0xFF, 0xC4]); + out.extend_from_slice(&((2 + 17 + vals.len()) as u16).to_be_bytes()); + out.push(*t); + out.extend_from_slice(counts); + out.extend_from_slice(vals); + } if let Some(r) = restart_rows { out.extend_from_slice(&[0xFF, 0xDD, 0, 4]); out.extend_from_slice(&((r * width) as u16).to_be_bytes()); @@ -178,8 +200,8 @@ pub fn lj92_encode(samples: &[u16], width: usize, height: usize, components: usi out.extend_from_slice(&[0xFF, 0xDA]); out.extend_from_slice(&((6 + 2 * components) as u16).to_be_bytes()); out.push(components as u8); - for c in 0..components { - out.extend_from_slice(&[c as u8 + 1, 0x00]); + for (c, &t) in table_of.iter().enumerate() { + out.extend_from_slice(&[c as u8 + 1, t << 4]); } out.extend_from_slice(&[predictor, 0, 0]); let mut w = BitWriter { out, acc: 0, n: 0 }; @@ -191,7 +213,7 @@ pub fn lj92_encode(samples: &[u16], width: usize, height: usize, components: usi rst = (rst + 1) % 8; } let s = category(d); - let (c, l) = code_of[s as usize]; + let (c, l) = code_of[usize::from(table_of[i % components])][s as usize]; w.put(c, l); if (1..16).contains(&s) { let extra = if d < 0 { (d - 1) as u32 & ((1 << s) - 1) } else { d as u32 }; @@ -487,6 +509,8 @@ pub struct DngSpec { pub model: String, /// Extra IFD0 entries, written after the generated ones (e.g. malformed colour tags). pub ifd0_extra: Vec<(u16, Val)>, + /// Extra raw-IFD entries, written after the generated ones (e.g. SampleFormat). + pub raw_extra: Vec<(u16, Val)>, } impl DngSpec { @@ -524,6 +548,7 @@ impl DngSpec { make: "Photocraft".into(), model: "Synthetic".into(), ifd0_extra: Vec::new(), + raw_extra: Vec::new(), } } @@ -772,6 +797,7 @@ impl DngSpec { if let Some(l) = &self.linearization { raw.push((50712, Val::Short(l.clone()))); } + raw.extend(self.raw_extra.iter().cloned()); let raw_ifd = t.ifd(raw); // IFD0: a tiny RGB thumbnail plus the DNG metadata. let thumb = t.blob(vec![128; 3 * 4]); @@ -847,6 +873,27 @@ pub fn gain_map_opcode_list(area: [u32; 4], pitch: u32, gains: [[f32; 2]; 2]) -> b } +/// An opcode list of MapPolynomial opcodes (ID 8), one per `(plane, coefficients)`, each over +/// `area` (top, left, bottom, right) at pitch 1. +pub fn map_polynomial_opcode_list(area: [u32; 4], planes: &[(u32, Vec)]) -> Vec { + let mut b = (planes.len() as u32).to_be_bytes().to_vec(); + for (plane, coefficients) in planes { + let mut p = Vec::new(); + let degree = coefficients.len().saturating_sub(1) as u32; + for v in [area[0], area[1], area[2], area[3], *plane, 1, 1, 1, degree] { + p.extend_from_slice(&v.to_be_bytes()); + } + for c in coefficients { + p.extend_from_slice(&c.to_be_bytes()); + } + for v in [8u32, 0x0103_0000, 0, p.len() as u32] { + b.extend_from_slice(&v.to_be_bytes()); + } + b.extend_from_slice(&p); + } + b +} + // ---------------------------------------------------------------- CR2 /// A synthetic CR2: one lossless JPEG with `components` components, cut into diff --git a/crates/raw/src/tiff.rs b/crates/raw/src/tiff.rs index 0fa11685f7..6b5c44d15d 100644 --- a/crates/raw/src/tiff.rs +++ b/crates/raw/src/tiff.rs @@ -116,12 +116,13 @@ impl Ifd { } } +/// Bytes per value of TIFF field type `typ`, BigTIFF's 8-byte types included, or 0 for an unknown type. pub(crate) fn type_size(typ: u16) -> usize { match typ { 1 | 2 | 6 | 7 => 1, 3 | 8 => 2, 4 | 9 | 11 | 13 => 4, - 5 | 10 | 12 => 8, + 5 | 10 | 12 | 16 | 17 | 18 => 8, _ => 0, } } @@ -304,6 +305,17 @@ impl<'a> Tiff<'a> { (!s.is_empty()).then_some(s) } + /// [`Tiff::ascii`] for diagnostics: reads at most `limit` bytes (and a few more, so a + /// character cut at the limit still decodes) of a value that may be gigabytes long and + /// shared by many entries. + pub fn ascii_prefix(&self, e: &Entry, limit: usize) -> Option { + let b = self.raw(e)?; + let b = b.get(..b.len().min(limit.saturating_add(4)))?; + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + let s = String::from_utf8_lossy(b.get(..end)?).trim().to_string(); + (!s.is_empty()).then_some(s) + } + pub fn tag_uint(&self, ifd: &Ifd, t: u16) -> Option { ifd.get(t).and_then(|e| self.uint(e, 0)) } @@ -344,7 +356,7 @@ pub(crate) fn dump(data: &[u8]) -> String { break 'ifds; } let preview: String = if e.typ == 2 { - let mut a = t.ascii(e).unwrap_or_default(); + let mut a = t.ascii_prefix(e, MAX_ASCII_PREVIEW).unwrap_or_default(); if a.len() > MAX_ASCII_PREVIEW { // Truncate at a char boundary at or below the cap (lossy UTF-8 can hold // multi-byte replacement chars). @@ -407,6 +419,23 @@ mod tests { assert_eq!(t.all_ifds().len(), 1); } + #[test] + fn eight_byte_types_are_read_from_their_offset() { + for typ in [16u16, 17, 18] { + let mut b = b"II*\0\x08\0\0\0".to_vec(); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&50000u16.to_le_bytes()); + b.extend_from_slice(&typ.to_le_bytes()); + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&26u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&0x0102_0304_0506_0708u64.to_le_bytes()); + let t = Tiff::new(&b).unwrap(); + let ifd = t.ifd_at(8, 0).unwrap(); + assert_eq!(t.raw(ifd.get(50000).unwrap()), Some(&0x0102_0304_0506_0708u64.to_le_bytes()[..]), "type {typ}"); + } + } + #[test] fn rational_with_zero_denominator_is_none() { let mut b = b"II*\0\x08\0\0\0".to_vec(); @@ -451,21 +480,21 @@ mod tests { assert!(out.len() < 2 << 20, "dump output was {} bytes", out.len()); assert!(out.contains("output truncated") || out.len() < 1 << 20); } -} -#[test] -fn linked_directory_values_are_read_only_as_needed() { - // The traversal limit is independent of the TIFF entry count: callers should - // never need a complete attacker-controlled offset array to visit SubIFDs. - let mut b = b"II*\0\x08\0\0\0".to_vec(); - b.extend_from_slice(&1u16.to_le_bytes()); - b.extend_from_slice(&330u16.to_le_bytes()); - b.extend_from_slice(&4u16.to_le_bytes()); - b.extend_from_slice(&(1_000_000u32).to_le_bytes()); - b.extend_from_slice(&26u32.to_le_bytes()); - b.extend_from_slice(&0u32.to_le_bytes()); - b.extend_from_slice(&0u32.to_le_bytes()); - b.resize(26 + 16 * 4, 0); - let t = Tiff::new(&b).unwrap(); - assert!(t.all_ifds().len() <= 17); + #[test] + fn linked_directory_values_are_read_only_as_needed() { + // The traversal limit is independent of the TIFF entry count: callers should + // never need a complete attacker-controlled offset array to visit SubIFDs. + let mut b = b"II*\0\x08\0\0\0".to_vec(); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&330u16.to_le_bytes()); + b.extend_from_slice(&4u16.to_le_bytes()); + b.extend_from_slice(&(1_000_000u32).to_le_bytes()); + b.extend_from_slice(&26u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.resize(26 + 16 * 4, 0); + let t = Tiff::new(&b).unwrap(); + assert!(t.all_ifds().len() <= 17); + } } diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index 25befbddea..003eb3c18c 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -66,7 +66,7 @@ pub(crate) fn cfa(t: &Tiff, raw: &Ifd, ifds: &[Ifd]) -> Option { /// Nikon maker note: "Nikon\0", version, then an embedded TIFF header whose /// offsets are relative to itself. -fn nikon_maker_note<'a>(t: &Tiff<'a>, ifds: &[Ifd]) -> Option<(Tiff<'a>, Ifd)> { +pub(crate) fn nikon_maker_note<'a>(t: &Tiff<'a>, ifds: &[Ifd]) -> Option<(Tiff<'a>, Ifd)> { let e = ifds.iter().find_map(|i| i.get(tag::MAKER_NOTE).copied())?; let b = t.raw(&e)?; if !b.starts_with(b"Nikon\0") { @@ -254,7 +254,7 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result a { ((e - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 }; - ((i - 1) as f32 + f) / N as f32 + (((i - 1) as f32 + f) / N as f32).powi(2) }; - let mut lin: Vec<[f32; 3]> = px.iter().map(|p| [0, 1, 2].map(|k| inverse(p[k]).powi(2))).collect(); - if let Some(haze) = Haze::new(w, h, |i| lin[i], tone.dehaze) { - for (i, l) in lin.iter_mut().enumerate() { - *l = haze.apply(i % w, i / w, *l); - } - } + let linear = |p: &[f32; 4]| [inverse(p[0]), inverse(p[1]), inverse(p[2])]; + // Full-size images (the detail preview develops 16–45 MP): rows in parallel, and the linear + // colours recomputed where needed instead of kept as a second copy of the image. + let haze = Haze::new(w, h, |i| linear(&px[i]), tone.dehaze); let y = crate::color::rgb_to_xyz(crate::color::ROMM, crate::color::D50_XY)[1].map(|v| v as f32); - let lum = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2]; - let map = ToneMap::new(w, h, |i| lum(&lin[i]), tone, 0.0); - for (i, (p, l)) in px.iter_mut().zip(&lin).enumerate() { - let g = map.as_ref().map_or(1.0, |m| m.gain(i % w, i / w, lum(l))); - for k in 0..3 { - p[k] = forward(l[k] * g); + let lum_of = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2]; + let dehazed = |i: usize| { + let l = linear(&px[i]); + haze.as_ref().map_or(l, |hz| hz.apply(i % w, i / w, l)) + }; + let rows = crate::par::band_rows(w); + let lum: Vec = crate::par::map(h.div_ceil(rows), |band| { + let (y0, y1) = (band * rows, ((band + 1) * rows).min(h)); + (y0 * w..y1 * w).map(|i| lum_of(&dehazed(i))).collect::>() + }) + .concat(); + let map = ToneMap::new(w, h, |i| lum[i], tone, 0.0); + let haze = haze.as_ref(); + crate::par::chunks_mut(&mut px[..w * h], rows * w, |band, chunk| { + for (j, p) in chunk.iter_mut().enumerate() { + let i = band * rows * w + j; + let mut l = [inverse(p[0]), inverse(p[1]), inverse(p[2])]; + if let Some(hz) = haze { + l = hz.apply(i % w, i / w, l); + } + let g = map.as_ref().map_or(1.0, |m| m.gain(i % w, i / w, lum[i])); + for k in 0..3 { + p[k] = forward(l[k] * g); + } } - } + }); } #[cfg(test)] diff --git a/crates/raw/tests/cr3.rs b/crates/raw/tests/it/cr3.rs similarity index 100% rename from crates/raw/tests/cr3.rs rename to crates/raw/tests/it/cr3.rs diff --git a/crates/raw/tests/development_limits.rs b/crates/raw/tests/it/development_limits.rs similarity index 100% rename from crates/raw/tests/development_limits.rs rename to crates/raw/tests/it/development_limits.rs diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/it/dng.rs similarity index 82% rename from crates/raw/tests/dng.rs rename to crates/raw/tests/it/dng.rs index 7cc04d2a92..ca9d895960 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/it/dng.rs @@ -1,7 +1,7 @@ //! Synthetic DNG files: every storage layout decodes to the exact sensor //! samples, and development reproduces known colours. -use photocraft_raw::testgen::{DngSpec, DngStorage, Val, baseline_jpeg, mosaic, scene}; +use photocraft_raw::testgen::{self, DngSpec, DngStorage, Val, baseline_jpeg, mosaic, scene}; use photocraft_raw::*; fn sensor(bytes: &[u8]) -> Sensor { @@ -685,6 +685,222 @@ fn jpeg_xl_linear_raw_develops() { assert_eq!(develop(&spec.build(), &DevelopOptions::default()).unwrap().rgb, d.rgb); } +/// A LinearRaw JPEG XL strip of `data` (16-bit samples) whose raw IFD says the samples are +/// floating-point (SampleFormat 3), as converters write lossy DNGs (#8125). The codestream is +/// an integer one (the test encoder writes no floats): the decoder reads it as floats in 0..=1, +/// the same values a float codestream of `data / 65535` holds. +fn float_jxl_dng(data: &[u16], w: usize, h: usize, white: Option) -> DngSpec { + let mut spec = DngSpec::cfa(w, h, data.to_vec()); + spec.samples = 3; + spec.white = 0; + spec.storage = DngStorage::JxlStrips { rows: h }; + spec.jxl_segments = vec![jxl(data, w, h, 3, 16)]; + spec.raw_extra = vec![(339, Val::Short(vec![3; 3]))]; + if let Some(white) = white { + spec.raw_extra.push((50717, Val::Double(vec![white; 3]))); + } + spec +} + +#[test] +fn floating_point_jpeg_xl_linear_raw_decodes() { + let (w, h) = (24, 16); + let data = noise(w * h * 3, 16); + // White level 1.0 (the default) becomes 65535: the samples read back as stored. + let s = sensor(&float_jxl_dng(&data, w, h, None).build()); + assert_eq!((s.width, s.height, s.samples), (w, h, 3)); + assert_eq!(s.data, data); + assert_eq!(s.white, [65535.0; 3]); + // A stated white level of 0.5 becomes 65535 too: samples double, those above it clip. + let s = sensor(&float_jxl_dng(&data, w, h, Some(0.5)).build()); + assert_eq!(s.white, [65535.0; 3]); + assert_eq!(s.data, data.iter().map(|&v| (u32::from(v) * 2).min(65535) as u16).collect::>()); + // It develops like the same samples stored as integers. + let pixels: Vec = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 20000.0) as u16)).collect(); + let float = develop(&float_jxl_dng(&pixels, w, h, None).build(), &DevelopOptions::default()).unwrap(); + let mut ints = DngSpec::cfa(w, h, pixels.clone()); + ints.samples = 3; + ints.white = 65535; + ints.storage = DngStorage::Strips { rows: h }; + assert_eq!(float.rgb, develop(&ints.build(), &DevelopOptions::default()).unwrap().rgb); +} + +#[test] +fn floating_point_raw_data_that_isnt_jpeg_xl_is_unsupported() { + let (w, h) = (8, 4); + let mut spec = float_jxl_dng(&noise(w * h * 3, 16), w, h, None); + spec.storage = DngStorage::Strips { rows: h }; + match decode(&spec.build(), &Limits::default()) { + Err(RawError::Unsupported(m)) => assert!(m.contains("floating-point"), "{m}"), + other => panic!("expected unsupported, got {other:?}"), + } +} + +/// WhiteLevel has one value per sample plane: the floats are scaled by the highest, so a plane +/// with a higher white level than the first keeps its highlights. +#[test] +fn floating_point_white_levels_per_plane_scale_from_the_highest() { + let (w, h) = (24, 16); + let data = noise(w * h * 3, 16); + let mut spec = float_jxl_dng(&data, w, h, None); + spec.raw_extra.push((50717, Val::Double(vec![0.5, 1.0, 0.5]))); + let s = sensor(&spec.build()); + assert_eq!(s.data, data, "scaled by 1.0, the highest: nothing clipped"); + assert_eq!(s.white, [32767.5, 65535.0, 32767.5]); +} + +/// `Σ cᵢ · xⁱ` at `x`, both clipped to 0..1 (as MapPolynomial). +fn poly(c: &[f64], x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + c.iter().rev().fold(0.0, |acc, c| acc * x + c).clamp(0.0, 1.0) +} + +/// #8125: a lossy DNG's OpcodeList2 carries a MapPolynomial per plane, and its colours depend on +/// it. Each plane's samples are mapped through its polynomial, on the linear reference values. +#[test] +fn map_polynomial_maps_each_plane_of_floating_point_data() { + let (w, h) = (24, 16); + let data = noise(w * h * 3, 16); + let planes = [vec![0.0, 0.5, 0.5], vec![0.0, 1.0], vec![0.1, 0.8, 0.0, 0.3]]; + let mut spec = float_jxl_dng(&data, w, h, None); + spec.opcode_list2 = Some(testgen::map_polynomial_opcode_list( + [0, 0, h as u32, w as u32], + &planes.iter().enumerate().map(|(p, c)| (p as u32, c.clone())).collect::>(), + )); + let bytes = spec.build(); + let s = sensor(&bytes); + let want: Vec = data.iter().enumerate().map(|(i, &v)| (poly(&planes[i % 3], f64::from(v) / 65535.0) * 65535.0).round() as u16).collect(); + assert_eq!(s.data, want); + assert!(s.warnings.iter().all(|w| !w.contains("MapPolynomial")), "{:?}", s.warnings); + let d = develop(&bytes, &DevelopOptions::default()).unwrap(); + assert!(d.warnings.iter().all(|w| !w.contains("MapPolynomial")), "{:?}", d.warnings); +} + +/// With a LinearizationTable, MapPolynomial maps the linearized values: the samples are +/// linearized first and the table is then done with. +#[test] +fn map_polynomial_follows_the_linearization_table() { + let (w, h) = (16, 8); + let data = noise(w * h, 12); + let table: Vec = (0..4096u16).map(|i| i * 2).collect(); + let mut spec = DngSpec::cfa(w, h, data.clone()); + spec.bits = 12; + spec.white = 8190; + spec.linearization = Some(table.clone()); + spec.opcode_list2 = Some(testgen::map_polynomial_opcode_list([0, 0, h as u32, w as u32], &[(0, vec![0.0, 0.0, 1.0])])); + let s = sensor(&spec.build()); + assert!(s.linearization.is_none(), "applied with the polynomial"); + let want: Vec = data.iter().map(|&v| (poly(&[0.0, 0.0, 1.0], f64::from(table[usize::from(v)]) / 8190.0) * 8190.0).round() as u16).collect(); + assert_eq!(s.data, want); +} + +/// A LinearizationTable maps integer samples: floating-point data ignores one. +#[test] +fn floating_point_raw_data_ignores_a_linearization_table() { + let (w, h) = (24, 16); + let data = noise(w * h * 3, 16); + let mut spec = float_jxl_dng(&data, w, h, None); + spec.linearization = Some(vec![0; 4]); + let s = sensor(&spec.build()); + assert!(s.linearization.is_none()); + assert_eq!(s.data, data); +} + +/// Floating-point samples are read into four bytes each before they become 16-bit: the limits +/// are charged for that buffer, not for the two-byte result. +#[test] +fn floating_point_jpeg_xl_is_charged_four_bytes_a_sample() { + let (w, h) = (24, 16); + let bytes = float_jxl_dng(&noise(w * h * 3, 16), w, h, None).build(); + let limits = Limits { max_alloc: (w * h * 3 * 2) as u64, ..Limits::default() }; + match decode(&bytes, &limits) { + Err(RawError::LimitExceeded(_)) => {} + other => panic!("expected the limits to refuse the f32 buffer, got {other:?}"), + } +} + +/// When the largest JPEG XL preview doesn't decode, the next largest is used. +#[test] +fn a_jpeg_xl_preview_that_does_not_decode_gives_way_to_a_smaller_one() { + use photocraft_raw::testgen::TiffBuilder; + let mut t = TiffBuilder::default(); + let preview = |t: &mut TiffBuilder, w: usize, h: usize, stream: Vec| { + let len = stream.len() as u32; + let strip = t.blob(stream); + t.ifd(vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![w as u32])), + (257, Val::Long(vec![h as u32])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![52546])), + (262, Val::Short(vec![2])), + (273, Val::Blobs(vec![strip])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![h as u32])), + (279, Val::Long(vec![len])), + ]) + }; + let small = noise(20 * 15 * 3, 8); + let damaged = preview(&mut t, 40, 30, vec![0xFF, 0x0A, 0, 0, 0, 0, 0, 0]); + let valid = preview(&mut t, 20, 15, jxl(&small, 20, 15, 3, 8)); + let ifd0 = t.ifd(vec![(254, Val::Long(vec![1])), (330, Val::Ifds(vec![damaged, valid])), (50706, Val::Byte(vec![1, 7, 0, 0]))]); + t.chain = vec![ifd0]; + let p = embedded_jxl_preview(&t.build(), &Limits::default()).expect("the 20×15 preview"); + assert_eq!((p.width, p.height, p.bits), (20, 15, 8)); + assert_eq!(p.rgb, small); +} + +/// Only a rendered RGB picture is a preview: a reduced-resolution LinearRaw image (NewSubFileType 1, +/// three samples, JPEG XL) holds undeveloped sensor data, so the RGB one is used even when smaller. +#[test] +fn a_reduced_resolution_linear_raw_is_not_a_jpeg_xl_preview() { + use photocraft_raw::testgen::TiffBuilder; + let mut t = TiffBuilder::default(); + let mut ifd = |w: usize, h: usize, photometric: u16, data: &[u16]| { + let stream = jxl(data, w, h, 3, 8); + let len = stream.len() as u32; + let strip = t.blob(stream); + t.ifd(vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![w as u32])), + (257, Val::Long(vec![h as u32])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![52546])), + (262, Val::Short(vec![photometric])), + (273, Val::Blobs(vec![strip])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![h as u32])), + (279, Val::Long(vec![len])), + ]) + }; + let small = noise(20 * 15 * 3, 8); + let linear_raw = ifd(40, 30, 34892, &noise(40 * 30 * 3, 8)); + let rgb = ifd(20, 15, 2, &small); + let ifd0 = t.ifd(vec![(254, Val::Long(vec![1])), (330, Val::Ifds(vec![linear_raw, rgb])), (50706, Val::Byte(vec![1, 7, 0, 0]))]); + t.chain = vec![ifd0]; + let p = embedded_jxl_preview(&t.build(), &Limits::default()).expect("the 20×15 RGB preview"); + assert_eq!((p.width, p.height), (20, 15)); + assert_eq!(p.rgb, small); +} + +/// A WhiteLevel too large for the sensor's levels (a DOUBLE past f32::MAX) is ignored, as a zero +/// one is: the samples keep the default white instead of an infinite one that blacks them out. +#[test] +fn an_infinite_white_level_falls_back_to_the_default() { + let (w, h) = (24, 16); + let pixels: Vec = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 20000.0) as u16)).collect(); + let mut ints = DngSpec::cfa(w, h, pixels.clone()); + ints.samples = 3; + ints.storage = DngStorage::Strips { rows: h }; + ints.white = 0; + ints.raw_extra.push((50717, Val::Double(vec![1e300; 3]))); + assert_eq!(sensor(&ints.build()).white, [65535.0; 3], "integer data"); + let float = float_jxl_dng(&pixels, w, h, Some(1e300)).build(); + assert_eq!(sensor(&float).white, [65535.0; 3], "floating-point data"); + let default = develop(&float_jxl_dng(&pixels, w, h, None).build(), &DevelopOptions::default()).unwrap(); + assert_eq!(develop(&float, &DevelopOptions::default()).unwrap().rgb, default.rgb); +} + #[test] fn jpeg_xl_mismatches_are_errors() { let (w, h) = (16, 8); diff --git a/crates/raw/tests/hostile.rs b/crates/raw/tests/it/hostile.rs similarity index 83% rename from crates/raw/tests/hostile.rs rename to crates/raw/tests/it/hostile.rs index 3212d554e1..6fdd0e7434 100644 --- a/crates/raw/tests/hostile.rs +++ b/crates/raw/tests/it/hostile.rs @@ -1,9 +1,32 @@ //! Truncated and corrupted raw files must fail cleanly, never panic, and //! never allocate beyond the limits. -use photocraft_raw::testgen::{Cr2Spec, DngSpec, DngStorage, RafPacking, RafSpec, XTRANS, mosaic, mosaic_cfa, orf, rw2, scene, sony_craw, tiff_ep}; +use photocraft_raw::testgen::{ + Cr2Spec, DngSpec, DngStorage, RafPacking, RafSpec, Val, XTRANS, mosaic, mosaic_cfa, nef_compressed, orf, rw2, scene, sony_craw, tiff_ep, +}; use photocraft_raw::*; +/// 12-bit samples packed little-endian, `w` per row, rows byte-aligned (Nikon's packed NEF layout). +fn pack12(values: &[u16], w: usize) -> Vec { + let mut out = Vec::new(); + for row in values.chunks_exact(w) { + let (mut acc, mut have) = (0u32, 0u32); + for v in row { + acc |= u32::from(*v) << have; + have += 12; + while have >= 8 { + out.push(acc as u8); + acc >>= 8; + have -= 8; + } + } + if have > 0 { + out.push(acc as u8); + } + } + out +} + /// Losslessly encodes 16-bit RGB as a JPEG XL codestream. fn jxl_rgb16(rgb: &[u16], w: usize, h: usize) -> Vec { use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions}; @@ -84,6 +107,8 @@ fn samples() -> Vec> { .build(), ); out.push(tiff_ep("NIKON", w, h, &cfa, [0, 1, 1, 2], 12, vec![])); + // A Nikon NEF (packed 12-bit) whose maker note says the previews are Adobe RGB (ColorSpace 0x001E). + out.push(nef_compressed(w, h, 12, pack12(&cfa, w), [0, 1, 1, 2], false, vec![(0x0093, Val::Short(vec![6])), (0x001E, Val::Short(vec![2]))])); let codes = mosaic(&scene(64, 6), 64, [0, 1, 1, 2], 256, 1900); out.push(sony_craw(64, 6, &codes, [8000, 10400, 12900, 14100])); out.push(rw2(30, 8, &mosaic(&scene(30, 8), 30, [0, 1, 1, 2], 128, 4095), 12)); @@ -126,6 +151,12 @@ fn originals_develop() { } } +#[test] +fn a_seed_states_a_maker_note_colour_space() { + // The truncation and corruption tests below only exercise the maker-note reader if a seed has one. + assert!(samples().iter().any(|b| embedded_preview_color_space(b) == Some(PreviewColorSpace::AdobeRgb))); +} + #[test] fn every_truncation_fails_cleanly() { for b in samples() { @@ -239,3 +270,18 @@ fn lossless_jpeg_frame_larger_than_the_budget_is_refused() { let budget = Limits { max_alloc: 256 << 10, ..Limits::default() }; assert!(matches!(decode(&b, &budget), Err(RawError::LimitExceeded(_)))); } + +#[test] +fn a_non_finite_black_level_develops_without_panicking() { + // A BlackLevel DOUBLE beyond f32 reads as inf: the planes must not turn into NaN and trip + // the AHD demosaic. + let (w, h) = (64, 48); + let mut sensor = decode(&DngSpec::cfa(w, h, vec![1000; w * h]).build(), &Limits::default()).unwrap(); + for black in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN] { + sensor.black = BlackLevels::uniform(black); + for demosaic in [Demosaic::Ahd, Demosaic::Bilinear, Demosaic::Mhc] { + let d = develop_sensor(&sensor, &DevelopOptions { demosaic, ..DevelopOptions::default() }); + assert!(d.is_ok(), "{black} {demosaic:?}: {:?}", d.err()); + } + } +} diff --git a/crates/raw/tests/it/main.rs b/crates/raw/tests/it/main.rs new file mode 100644 index 0000000000..27a1af788b --- /dev/null +++ b/crates/raw/tests/it/main.rs @@ -0,0 +1,12 @@ +//! The crate's integration tests, linked into one test binary. + +mod cr3; +mod development_limits; +mod dng; +mod hostile; +mod nef_packed; +mod nikon_d4_depth; +mod raf; +mod raw_corpus; +mod vendor_codecs; +mod vendors; diff --git a/crates/raw/tests/nef_packed.rs b/crates/raw/tests/it/nef_packed.rs similarity index 100% rename from crates/raw/tests/nef_packed.rs rename to crates/raw/tests/it/nef_packed.rs diff --git a/crates/raw/tests/it/nikon_d4_depth.rs b/crates/raw/tests/it/nikon_d4_depth.rs new file mode 100644 index 0000000000..26076c7e88 --- /dev/null +++ b/crates/raw/tests/it/nikon_d4_depth.rs @@ -0,0 +1,44 @@ +//! The Nikon D4 profile (`cameras.rs`) was measured on 14-bit NEFs. The camera also writes 12-bit +//! files (lossless, lossy and uncompressed) whose samples are the 14-bit values shifted right by +//! two, so the profile's saturation level must follow the sample depth: a 12-bit D4 frame has to +//! develop as bright as the same scene at 14 bits. + +use photocraft_raw::testgen::{Val, tiff_ep}; +use photocraft_raw::{Demosaic, DevelopOptions, Limits, Rect, WhiteBalance, decode, develop_sensor}; + +const WIDTH: usize = 4992; +const HEIGHT: usize = 3292; + +/// A synthetic D4 NEF: a smooth ramp, 14-bit values (3000..7000, well below the saturation +/// level) stored at `bits`. +fn d4_nef(bits: u16) -> Vec { + let shift = 14 - u32::from(bits); + let data: Vec = (0..WIDTH * HEIGHT).map(|i| (3000 + ((i % WIDTH) + 2 * (i / WIDTH)) % 4000) as u16 >> shift).collect(); + tiff_ep("NIKON CORPORATION", WIDTH, HEIGHT, &data, [0, 1, 1, 2], bits, vec![(272, Val::Ascii("NIKON D4".into()))]) +} + +/// Mean linear luminance of the developed ProPhoto frame over a small window of the image. +fn mean_luminance(bits: u16) -> (f32, f64) { + let mut s = decode(&d4_nef(bits), &Limits::default()).expect("a synthetic D4 NEF decodes"); + let white = s.white[1]; + // Only a window is developed: the profile is keyed on the full sensor size, the test is not. + s.crop = Rect::new(2000, 1000, 64, 64); + let opts = DevelopOptions { demosaic: Demosaic::Bilinear, white_balance: WhiteBalance::Multipliers([2.0, 1.0, 1.5]), ..DevelopOptions::default() }; + let d = develop_sensor(&s, &opts).expect("a synthetic D4 NEF develops"); + let lin = |v: u16| (f64::from(v) / 65535.0).powf(1.8); + let pixels = d.rgb.as_chunks::<3>().0; + let y: f64 = pixels.iter().map(|p| 0.2880402 * lin(p[0]) + 0.7118741 * lin(p[1]) + 0.0000857 * lin(p[2])).sum(); + (white, y / pixels.len() as f64) +} + +#[test] +fn a_12_bit_d4_nef_develops_as_bright_as_the_same_scene_at_14_bit() { + let (white14, y14) = mean_luminance(14); + let (white12, y12) = mean_luminance(12); + let stops = (y12 / y14).log2(); + assert!(stops.abs() < 0.2, "12-bit differs from 14-bit by {stops:.2} stops (mean Y {y12:.4} vs {y14:.4})"); + // The 14-bit level is the measured one, untouched. + assert_eq!(white14, 15520.0); + // The 12-bit level is the same fraction of the (4095-range) data. + assert_eq!(white12, 3880.0); +} diff --git a/crates/raw/tests/raf.rs b/crates/raw/tests/it/raf.rs similarity index 100% rename from crates/raw/tests/raf.rs rename to crates/raw/tests/it/raf.rs diff --git a/crates/raw/tests/raw_corpus.rs b/crates/raw/tests/it/raw_corpus.rs similarity index 100% rename from crates/raw/tests/raw_corpus.rs rename to crates/raw/tests/it/raw_corpus.rs diff --git a/crates/raw/tests/vendor_codecs.rs b/crates/raw/tests/it/vendor_codecs.rs similarity index 100% rename from crates/raw/tests/vendor_codecs.rs rename to crates/raw/tests/it/vendor_codecs.rs diff --git a/crates/raw/tests/vendors.rs b/crates/raw/tests/it/vendors.rs similarity index 100% rename from crates/raw/tests/vendors.rs rename to crates/raw/tests/it/vendors.rs diff --git a/crates/tablet/Cargo.toml b/crates/tablet/Cargo.toml index 8f6ad08fca..83e29c4d68 100644 --- a/crates/tablet/Cargo.toml +++ b/crates/tablet/Cargo.toml @@ -19,7 +19,7 @@ todo = "warn" # Already in Cargo.lock (eframe/wgpu/fmv-macos-events and winit pull them in); pinned exactly: # review the diff before bumping. [target.'cfg(target_os = "macos")'.dependencies] -objc2 = "=0.6.4" +objc2 = "=0.6.5" block2 = "=0.6.2" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSGeometry"] } objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "block2", "NSEvent"] } diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index 3e8bbed919..89f996bbea 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -8,9 +8,6 @@ license.workspace = true workspace = true [dependencies] -photocraft-geom = { workspace = true } -photocraft-color = { workspace = true } -photocraft-raster = { workspace = true } serde_json = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 03af4b630f..06f70a4e2d 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -4,5 +4,7 @@ pub mod gpu; #[cfg(not(target_arch = "wasm32"))] pub mod perf; +pub mod sweep; pub use gpu::gpu_lock; +pub use sweep::Sweep; diff --git a/crates/testkit/src/sweep.rs b/crates/testkit/src/sweep.rs new file mode 100644 index 0000000000..863a5c9029 --- /dev/null +++ b/crates/testkit/src/sweep.rs @@ -0,0 +1,60 @@ +//! Long test sweeps spread over several days. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The cases of a long sweep that this run covers. By default a run takes one of `parts` slices, +/// chosen by the UTC date, so every case runs at least once every `parts` days. +/// `PHOTOCRAFT_FULL_SWEEP=1` runs every case, and `PHOTOCRAFT_SWEEP_PART=` replays slice `n`; +/// [`Sweep::new`] prints the slice it picked. +#[derive(Debug, Clone, Copy)] +pub struct Sweep { + part: u64, + parts: u64, +} + +impl Sweep { + pub fn new(parts: u64) -> Self { + if std::env::var_os("PHOTOCRAFT_FULL_SWEEP").is_some_and(|v| v == "1") { + eprintln!("full sweep (PHOTOCRAFT_FULL_SWEEP=1)"); + return Self { part: 0, parts: 1 }; + } + let parts = parts.max(1); + let part = match std::env::var("PHOTOCRAFT_SWEEP_PART") { + Ok(v) => { + let p = v.parse::().unwrap_or(u64::MAX); + assert!(p < parts, "PHOTOCRAFT_SWEEP_PART={v:?} is not a part number below {parts}"); + p + } + Err(_) => SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86_400).unwrap_or_default() % parts, + }; + eprintln!("sweep part {part} of {parts}: replay with PHOTOCRAFT_SWEEP_PART={part}, run every part with PHOTOCRAFT_FULL_SWEEP=1"); + Self { part, parts } + } + + /// Whether case `i` runs. Cases go to slices by a hash of `i`, so each slice mixes every + /// position of the sweep's inner loops instead of taking every `parts`-th one. + pub fn includes(&self, i: u64) -> bool { + self.parts == 1 || mix(i) % self.parts == self.part + } +} + +/// splitmix64's finalizer. +fn mix(mut x: u64) -> u64 { + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_parts_cover_every_case_once() { + let parts: Vec = (0..4).map(|part| Sweep { part, parts: 4 }).collect(); + for i in 0..1000 { + assert_eq!(parts.iter().filter(|s| s.includes(i)).count(), 1, "case {i}"); + } + assert!(parts.iter().all(|s| (0..1000).filter(|&i| s.includes(i)).count() > 200)); + } +} diff --git a/crates/text/src/area.rs b/crates/text/src/area.rs new file mode 100644 index 0000000000..407065355d --- /dev/null +++ b/crates/text/src/area.rs @@ -0,0 +1,590 @@ +//! Area type: text that wraps inside a closed path, like Photoshop's area type. +//! +//! The path is flattened once into edges. For a line's band (ascent to descent), +//! [`AreaShape::spans`] gives the intervals that are inside the shape for the whole band, left +//! to right. A shape with dents or holes gives several spans on a row, and the layout fills +//! them in order at the same baseline, as Photoshop does. + +use std::ops::Range; + +use photocraft_doc::vector::{FillRule, Path, PathOp}; +use photocraft_geom::Point; + +/// Pieces each curved segment is cut into (about 0.1 px off a 300 px bubble's curve). +const CURVE_PIECES: usize = 16; +/// Most anchors an area path can have: the most `type.fillPath` accepts and a Type tool click +/// targets (`path_target` in ui-egui). +pub const MAX_AREA_KNOTS: usize = 4096; +/// Largest number of edges an area keeps, so a hostile path can't make every line scan millions. +/// A closed subpath of `n` anchors gives at most `CURVE_PIECES * n + 1` edges, so every path +/// within [`MAX_AREA_KNOTS`] fits, even with every segment curved. +pub const MAX_AREA_EDGES: usize = MAX_AREA_KNOTS * (CURVE_PIECES + 1); +/// Most shape components an area keeps: every row folds each one in, so a path of thousands of +/// tiny shapes can't make each line cost thousands of interval merges. +pub const MAX_AREA_COMPONENTS: usize = 4096; +/// Largest coordinate magnitude an area accepts, so f32 math on it can't overflow. +const MAX_COORD: f64 = 1e7; +/// Most baselines `next_row` tries before giving up. +const MAX_ROW_STEPS: usize = 1024; + +#[derive(Clone, Copy, Debug)] +struct Edge { + x0: f32, + y0: f32, + x1: f32, + y1: f32, + /// Lower and upper y of the edge. + lo: f32, + hi: f32, +} + +#[derive(Clone, Debug)] +struct Component { + op: PathOp, + /// Range into `AreaShape::edges`, sorted by `lo`. + edges: Range, + /// Tallest edge in the range, so a query only looks at edges whose `lo` is within it. + max_h: f32, +} + +/// A closed path ready for line fitting, in area space (text space, or `(y, -x)` for vertical +/// type so its rows are columns). +#[derive(Clone, Debug)] +pub struct AreaShape { + edges: Vec, + comps: Vec, + rule: FillRule, + /// Extent of the edges: `[x0, y0, x1, y1]`. + pub bounds: [f32; 4], +} + +fn cubic(s: &[Point; 4], t: f64) -> (f64, f64) { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + (a * s[0].x + b * s[1].x + c * s[2].x + d * s[3].x, a * s[0].y + b * s[1].y + c * s[2].y + d * s[3].y) +} + +fn same(a: Point, b: Point) -> bool { + (a.x - b.x).abs() < 1e-9 && (a.y - b.y).abs() < 1e-9 +} + +/// f64 → f32, rejecting values that are not finite or are beyond [`MAX_COORD`]. +fn small(v: f64) -> Option { + let f = v as f32; + (v.abs() <= MAX_COORD && f.is_finite()).then_some(f) +} + +impl AreaShape { + /// `None` when the path encloses nothing: no closed subpath, no height or width, an + /// inverted (unbounded) path, coordinates that aren't finite, or more than + /// [`MAX_AREA_EDGES`] edges or [`MAX_AREA_COMPONENTS`] components. A component with no + /// closed subpath encloses nothing and is left out, so an open path drawn with Intersect + /// doesn't empty the shape. + pub fn new(path: &Path, vertical: bool) -> Option { + if path.inverted { + return None; + } + let map = |x: f64, y: f64| -> Option<(f32, f32)> { if vertical { Some((small(y)?, small(-x)?)) } else { Some((small(x)?, small(y)?)) } }; + let mut edges: Vec = Vec::new(); + let mut comps: Vec = Vec::new(); + for range in path.components() { + let op = path.effective_op(range.start); + let start = edges.len(); + for sub in path.subpaths.get(range).unwrap_or(&[]) { + if !sub.closed { + continue; + } + let mut pts: Vec<(f32, f32)> = Vec::new(); + for seg in sub.segments() { + if pts.is_empty() { + pts.push(map(seg[0].x, seg[0].y)?); + } + if same(seg[1], seg[0]) && same(seg[2], seg[3]) { + pts.push(map(seg[3].x, seg[3].y)?); + } else { + for j in 1..=CURVE_PIECES { + let (x, y) = cubic(&seg, j as f64 / CURVE_PIECES as f64); + pts.push(map(x, y)?); + } + } + if edges.len().saturating_add(pts.len()) > MAX_AREA_EDGES { + return None; + } + } + // Closing edge back to the first point. + if let Some(&first) = pts.first() { + pts.push(first); + } + for w in pts.windows(2) { + if let [(x0, y0), (x1, y1)] = *w { + edges.push(Edge { x0, y0, x1, y1, lo: y0.min(y1), hi: y0.max(y1) }); + } + } + if edges.len() > MAX_AREA_EDGES { + return None; + } + } + if edges.len() == start { + continue; + } + if comps.len() >= MAX_AREA_COMPONENTS { + return None; + } + if let Some(mine) = edges.get_mut(start..) { + mine.sort_by(|a, b| a.lo.total_cmp(&b.lo)); + } + let max_h = edges.get(start..).unwrap_or(&[]).iter().map(|e| e.hi - e.lo).fold(0.0f32, f32::max); + comps.push(Component { op, edges: start..edges.len(), max_h }); + } + let mut b = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; + for e in &edges { + b = [b[0].min(e.x0).min(e.x1), b[1].min(e.y0).min(e.y1), b[2].max(e.x0).max(e.x1), b[3].max(e.y0).max(e.y1)]; + } + if edges.is_empty() || !(b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0) { + return None; + } + Some(AreaShape { edges, comps, rule: path.fill_rule, bounds: b }) + } + + fn filled(&self, winding: i32) -> bool { + match self.rule { + FillRule::NonZero => winding != 0, + FillRule::EvenOdd => winding & 1 != 0, + } + } + + /// Edges of `c` that may overlap `[ya, yb]` (a superset), found by binary search. + fn candidates(&self, c: &Component, ya: f32, yb: f32) -> &[Edge] { + let all = self.edges.get(c.edges.clone()).unwrap_or(&[]); + // Slack covers f32 rounding of `max_h` and of the subtraction; extras are filtered exactly later. + let slack = (ya.abs() + c.max_h) * 1e-6 + 1e-3; + let from = all.partition_point(|e| e.lo < ya - c.max_h - slack); + let to = all.partition_point(|e| e.lo <= yb); + all.get(from..to.max(from)).unwrap_or(&[]) + } + + /// Inside intervals of one component on row `y` (half-open in y, so a vertex is counted once). + fn component_intervals(&self, c: &Component, y: f32) -> Vec<(f32, f32)> { + let mut xs: Vec<(f32, i32)> = Vec::new(); + for e in self.candidates(c, y, y) { + if e.lo <= y && y < e.hi { + let dir = if e.y0 < e.y1 { 1 } else { -1 }; + let t = (y - e.y0) / (e.y1 - e.y0); + xs.push((e.x0 + t * (e.x1 - e.x0), dir)); + } + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out = Vec::new(); + let (mut winding, mut start) = (0i32, 0.0f32); + for (x, dir) in xs { + let was = self.filled(winding); + winding = winding.saturating_add(dir); + let now = self.filled(winding); + if !was && now { + start = x; + } else if was && !now && x > start { + out.push((start, x)); + } + } + out + } + + /// Inside intervals of the whole path on row `y`, components folded in with their ops. + fn intervals_at(&self, y: f32) -> Vec<(f32, f32)> { + let mut acc: Vec<(f32, f32)> = Vec::new(); + for c in &self.comps { + let v = self.component_intervals(c, y); + acc = match c.op { + PathOp::Combine | PathOp::Join => boolean(&acc, &v, |a, b| a || b), + PathOp::Subtract => boolean(&acc, &v, |a, b| a && !b), + PathOp::Intersect => boolean(&acc, &v, |a, b| a && b), + PathOp::Exclude => boolean(&acc, &v, |a, b| a != b), + }; + } + acc + } + + /// The x-ranges swept by the edges of `c` while inside `[y0, y1]`, sorted and merged. + fn swept(&self, c: &Component, y0: f32, y1: f32) -> Vec<(f32, f32)> { + let mut v: Vec<(f32, f32)> = Vec::new(); + for e in self.candidates(c, y0, y1) { + if e.hi < y0 || e.lo > y1 { + continue; + } + if e.lo == e.hi { + // A horizontal edge only matters strictly inside the band. + if e.lo > y0 && e.lo < y1 { + v.push((e.x0.min(e.x1), e.x0.max(e.x1))); + } + continue; + } + let at = |y: f32| e.x0 + (y - e.y0) / (e.y1 - e.y0) * (e.x1 - e.x0); + let (xa, xb) = (at(e.lo.max(y0)), at(e.hi.min(y1))); + v.push((xa.min(xb), xa.max(xb))); + } + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f32, f32)> = Vec::new(); + for (l, r) in v { + match out.last_mut() { + Some(last) if l <= last.1 => last.1 = last.1.max(r), + _ => out.push((l, r)), + } + } + out + } + + /// Intervals inside the shape on every row of `[y0, y1]`, left to right, each at least + /// `min_width` wide. + /// + /// Within one component a point is inside for the whole band exactly when it is inside on + /// the middle row and none of that component's edges crosses its vertical segment + /// (`whole`); it is inside on some row when either holds (`ever`). The components then fold + /// together with their ops, so an edge hidden inside another component's fill (the inner + /// edges of a speech bubble's tail) never cuts the row, and text never sits over a cutout: + /// Subtract takes away everything the cut ever covers. + pub fn spans(&self, y0: f32, y1: f32, min_width: f32) -> Vec<(f32, f32)> { + if !(y0.is_finite() && y1.is_finite()) || y1 < y0 { + return Vec::new(); + } + let mid = y0 + (y1 - y0) * 0.5; + let mut whole: Vec<(f32, f32)> = Vec::new(); + let mut ever: Vec<(f32, f32)> = 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); + (whole, ever) = match c.op { + PathOp::Combine | PathOp::Join => (boolean(&whole, &w, |a, b| a || b), boolean(&ever, &e, |a, b| a || b)), + 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 => { + let both = boolean(&whole, &w, |a, b| a && b); + let x = boolean(&boolean(&whole, &e, |a, b| a && !b), &boolean(&w, &ever, |a, b| a && !b), |a, b| a || b); + (x, boolean(&boolean(&ever, &e, |a, b| a || b), &both, |a, b| a && !b)) + } + }; + } + 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 + /// at least `min_width` wide, with those spans. `None` once the band leaves the shape. + pub fn next_row(&self, from: f32, ascent: f32, descent: f32, min_width: f32) -> Option<(f32, Vec<(f32, f32)>)> { + if !(from.is_finite() && ascent.is_finite() && descent.is_finite()) { + return None; + } + let from = from.max(self.bounds[1] + ascent); + let step = ((self.bounds[3] - self.bounds[1]) / 512.0).max(0.5); + let mut b = from; + for _ in 0..MAX_ROW_STEPS { + if b + descent > self.bounds[3] { + return None; + } + let s = self.spans(b - ascent, b + descent, min_width); + if !s.is_empty() { + return Some((b, s)); + } + b += step; + } + None + } + + /// Is `(x, y)` (area space) inside the shape? + pub fn contains(&self, x: f32, y: f32) -> bool { + self.intervals_at(y).iter().any(|&(l, r)| l <= x && x < r) + } +} + +/// Bounds `(x0, y0, x1, y1)` of an area type path's anchors and control points, when every point +/// is finite and within the area coordinate limit (`1e7`), the same paths [`AreaShape::new`] +/// accepts. `None` for an empty path or one with a NaN, infinite or huge coordinate, so callers +/// that turn the bounds into a text box or a PSD rectangle never write NaN. +pub fn path_bounds(path: &Path) -> Option<(f64, f64, f64, f64)> { + let ok = |p: Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD; + if !path.subpaths.iter().flat_map(|s| &s.knots).all(|k| ok(k.anchor) && ok(k.in_ctrl) && ok(k.out_ctrl)) { + return None; + } + path.control_bounds() +} + +/// 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() }; + let (ea, eb) = (ends(a), ends(b)); + let mut xs: Vec = Vec::with_capacity(ea.len() + eb.len()); + let (mut i, mut j) = (0, 0); + while i < ea.len() || j < eb.len() { + let next = match (ea.get(i), eb.get(j)) { + (Some(&p), Some(&q)) if q < p => { + j += 1; + q + } + (Some(&p), _) => { + i += 1; + p + } + (None, Some(&q)) => { + j += 1; + q + } + (None, None) => break, + }; + if xs.last() != Some(&next) { + xs.push(next); + } + } + let (mut ia, mut ib) = (0usize, 0usize); + let mut out: Vec<(f32, f32)> = Vec::new(); + for w in xs.windows(2) { + let &[l, r] = w else { continue }; + let mid = (l + r) * 0.5; + while a.get(ia).is_some_and(|s| s.1 <= mid) { + ia += 1; + } + while b.get(ib).is_some_and(|s| s.1 <= mid) { + ib += 1; + } + let in_a = a.get(ia).is_some_and(|s| s.0 <= mid); + let in_b = b.get(ib).is_some_and(|s| s.0 <= mid); + if f(in_a, in_b) { + match out.last_mut() { + Some(last) if last.1 == l => last.1 = r, + _ => out.push((l, r)), + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::vector::{FillRule, Knot, Path, PathOp, Subpath}; + + fn poly(pts: &[(f64, f64)]) -> Path { + Path::new(vec![Subpath::polygon(pts)]) + } + + fn close(a: (f32, f32), b: (f32, f32)) -> bool { + (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01 + } + + #[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(); + assert_eq!(a.bounds, [0.0, 0.0, 100.0, 50.0]); + let s = a.spans(10.0, 20.0, 1.0); + assert_eq!(s.len(), 1); + assert!(close(s[0], (0.0, 100.0)), "{s:?}"); + // A band leaving the shape has no span. + assert!(a.spans(40.0, 60.0, 1.0).is_empty()); + } + + #[test] + fn triangle_band_is_its_narrowest_row() { + // Apex at the top: the width at y is y. + let a = AreaShape::new(&poly(&[(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), false).unwrap(); + let s = a.spans(40.0, 60.0, 1.0); + assert_eq!(s.len(), 1); + assert!(close(s[0], (30.0, 70.0)), "{s:?}"); + // Narrower than min_width: dropped. + assert!(a.spans(5.0, 10.0, 20.0).is_empty()); + } + + #[test] + fn hole_splits_the_row() { + let outer = Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + let hole = Subpath::polygon(&[(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]).with_op(PathOp::Subtract); + let a = AreaShape::new(&Path::new(vec![outer.clone(), hole]), false).unwrap(); + let s = a.spans(45.0, 55.0, 1.0); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(close(s[0], (0.0, 40.0)) && close(s[1], (60.0, 100.0)), "{s:?}"); + // The same hole as a joined subpath under even-odd (a custom shape's counter). + let joined = Subpath::polygon(&[(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]).with_op(PathOp::Join); + let mut p = Path::new(vec![outer, joined]); + p.fill_rule = FillRule::EvenOdd; + assert_eq!(AreaShape::new(&p, false).unwrap().spans(45.0, 55.0, 1.0).len(), 2); + } + + #[test] + fn intersect_and_exclude_follow_photoshop_ops() { + let a = Subpath::polygon(&[(0.0, 0.0), (60.0, 0.0), (60.0, 10.0), (0.0, 10.0)]); + let b = |op| Subpath::polygon(&[(40.0, 0.0), (100.0, 0.0), (100.0, 10.0), (40.0, 10.0)]).with_op(op); + let i = AreaShape::new(&Path::new(vec![a.clone(), b(PathOp::Intersect)]), false).unwrap().spans(2.0, 8.0, 1.0); + assert!(i.len() == 1 && close(i[0], (40.0, 60.0)), "{i:?}"); + let x = AreaShape::new(&Path::new(vec![a, b(PathOp::Exclude)]), false).unwrap().spans(2.0, 8.0, 1.0); + assert!(x.len() == 2 && close(x[0], (0.0, 40.0)) && close(x[1], (60.0, 100.0)), "{x:?}"); + } + + #[test] + fn curves_are_flattened() { + // A circle of radius 50 centred on (50, 50) from four kappa curves. + let k = 0.552_284_75 * 50.0; + let c = |x: f64, y: f64, ix: f64, iy: f64, ox: f64, oy: f64| { + Knot::smooth(photocraft_geom::Point::new(x, y), photocraft_geom::Point::new(ix, iy), photocraft_geom::Point::new(ox, oy)) + }; + let circle = Subpath { + closed: true, + knots: vec![ + c(50.0, 0.0, 50.0 - k, 0.0, 50.0 + k, 0.0), + c(100.0, 50.0, 100.0, 50.0 - k, 100.0, 50.0 + k), + c(50.0, 100.0, 50.0 + k, 100.0, 50.0 - k, 100.0), + c(0.0, 50.0, 0.0, 50.0 + k, 0.0, 50.0 - k), + ], + op: PathOp::Combine, + }; + let a = AreaShape::new(&Path::new(vec![circle]), false).unwrap(); + let s = a.spans(49.0, 51.0, 1.0); + assert!(s.len() == 1 && s[0].0 < 1.0 && s[0].1 > 99.0, "{s:?}"); + assert!(a.contains(50.0, 50.0) && !a.contains(3.0, 3.0)); + } + + /// A 260×160 ellipse at the origin from four kappa curves. + fn ellipse() -> Subpath { + let (cx, cy, rx, ry) = (130.0, 80.0, 130.0, 80.0); + let (kx, ky) = (0.552_284_75 * rx, 0.552_284_75 * ry); + let p = photocraft_geom::Point::new; + let c = |x: f64, y: f64, ix: f64, iy: f64, ox: f64, oy: f64| Knot::smooth(p(x, y), p(ix, iy), p(ox, oy)); + Subpath { + closed: true, + knots: vec![ + c(cx, cy - ry, cx - kx, cy - ry, cx + kx, cy - ry), + c(cx + rx, cy, cx + rx, cy - ky, cx + rx, cy + ky), + c(cx, cy + ry, cx + kx, cy + ry, cx - kx, cy + ry), + c(cx - rx, cy, cx - rx, cy + ky, cx - rx, cy - ky), + ], + op: PathOp::Combine, + } + } + + /// The speech bubble: the ellipse combined with a tail whose wide end sits inside it. + fn bubble() -> AreaShape { + let tail = Subpath::polygon(&[(120.0, 64.0), (180.0, 64.0), (230.0, 210.0)]).with_op(PathOp::Combine); + AreaShape::new(&Path::new(vec![ellipse(), tail]), false).unwrap() + } + + #[test] + fn combined_overlap_hides_inner_edges() { + let a = bubble(); + // Rows through the overlap: the tail's edges inside the ellipse must not cut the row. + for y0 in [56.0f32, 70.0, 90.0, 110.0, 120.0] { + let s = a.spans(y0, y0 + 18.0, 1.0); + assert_eq!(s.len(), 1, "band {y0}: {s:?}"); + // The ellipse's own edge bounds the row on both sides. + let dy = (y0 - 80.0).abs().max((y0 + 18.0 - 80.0).abs()); + let half = 130.0 * (1.0 - (dy / 80.0).powi(2)).sqrt(); + assert!((s[0].0 - (130.0 - half)).abs() < 0.5 && (s[0].1 - (130.0 + half)).abs() < 0.5, "band {y0}: {s:?}"); + } + // Lower down the tail pokes out on the right: still one span, out to the tail's edge. + let s = a.spans(130.0, 148.0, 1.0); + assert!(s.len() == 1 && s[0].1 > 202.0, "{s:?}"); + // Below the ellipse only the tail is left. + let s = a.spans(165.0, 183.0, 1.0); + assert!(s.len() == 1 && s[0].0 > 190.0 && s[0].1 < 220.0, "{s:?}"); + } + + #[test] + fn component_without_edges_is_left_out() { + let rect = Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)]); + let open = Subpath::polyline(&[(10.0, 10.0), (90.0, 40.0)]).with_op(PathOp::Intersect); + let a = AreaShape::new(&Path::new(vec![rect, open]), false).unwrap(); + let s = a.spans(10.0, 20.0, 1.0); + assert!(s.len() == 1 && close(s[0], (0.0, 100.0)), "{s:?}"); + } + + #[test] + fn too_many_components_give_no_area() { + let square = |i: usize| { + let x = (i % 100) as f64 * 3.0; + let y = (i / 100) as f64 * 3.0; + Subpath::polygon(&[(x, y), (x + 2.0, y), (x + 2.0, y + 2.0), (x, y + 2.0)]) + }; + let at_cap: Vec = (0..MAX_AREA_COMPONENTS).map(square).collect(); + assert!(AreaShape::new(&Path::new(at_cap), false).is_some()); + let over: Vec = (0..=MAX_AREA_COMPONENTS).map(square).collect(); + assert!(AreaShape::new(&Path::new(over), false).is_none()); + } + + #[test] + fn path_bounds_need_finite_small_points() { + assert_eq!(path_bounds(&poly(&[(1.0, 2.0), (30.0, 2.0), (30.0, 40.0)])), Some((1.0, 2.0, 30.0, 40.0))); + assert_eq!(path_bounds(&Path::default()), None); + for bad in [f64::NAN, f64::INFINITY, -f64::INFINITY, 2e7] { + // Anywhere in the path, not just the first point (`f64::min` skips a NaN). + assert_eq!(path_bounds(&poly(&[(0.0, 0.0), (10.0, bad), (10.0, 10.0)])), None, "{bad}"); + assert_eq!(path_bounds(&poly(&[(bad, 0.0), (10.0, 0.0), (10.0, 10.0)])), None, "{bad}"); + } + } + + #[test] + fn next_row_skips_the_narrow_top() { + let a = AreaShape::new(&poly(&[(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), false).unwrap(); + // Ascent 8: the band's top (b - 8) needs width ≥ 30, so b ≥ 38. + let (b, s) = a.next_row(8.0, 8.0, 2.0, 30.0).unwrap(); + assert!((b - 38.0).abs() <= 0.51, "{b}"); + assert!(s[0].1 - s[0].0 >= 30.0); + // Past the bottom: no row. + assert!(a.next_row(99.0, 8.0, 2.0, 1.0).is_none()); + } + + #[test] + fn next_row_from_far_above_still_finds_rows() { + let a = AreaShape::new(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)]), false).unwrap(); + let (b, s) = a.next_row(-1000.0, 8.0, 2.0, 10.0).unwrap(); + assert!((b - 8.0).abs() < 0.6 && close(s[0], (0.0, 100.0)), "{b} {s:?}"); + } + + #[test] + fn tall_edge_survives_rounding_at_its_end() { + // The tallest edge's height and `y - height` both round; the lookup must still find it. + let a = AreaShape::new(&poly(&[(0.0, -1898.2166), (10.0, 212.32651), (0.0, 212.32651)]), false).unwrap(); + let y = 212.32649_f32; + assert!(a.contains(5.0, y)); + let s = a.spans(y, y, 1.0); + assert!(s.len() == 1 && s[0].0 < 0.01 && s[0].1 > 9.9, "{s:?}"); + } + + #[test] + fn slanted_hole_is_never_spanned() { + let outer = Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + let strip = || Subpath::polygon(&[(5.0, 0.0), (8.0, 0.0), (98.0, 100.0), (95.0, 100.0)]); + let sub = Path::new(vec![outer.clone(), strip().with_op(PathOp::Subtract)]); + let mut eo = Path::new(vec![outer, strip().with_op(PathOp::Join)]); + eo.fill_rule = FillRule::EvenOdd; + for p in [sub, eo] { + let s = AreaShape::new(&p, false).unwrap().spans(40.0, 60.0, 1.0); + assert!(!s.is_empty()); + assert!(s.iter().all(|&(l, r)| r <= 50.0 || l >= 53.0), "{s:?}"); + } + } + + #[test] + fn vertical_maps_columns_right_to_left() { + // Text space 100 wide, 50 tall; area space is (y, -x): x in 0..50, y in -100..0. + let a = AreaShape::new(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)]), true).unwrap(); + assert_eq!(a.bounds, [0.0, -100.0, 50.0, 0.0]); + let s = a.spans(-60.0, -40.0, 1.0); + assert!(s.len() == 1 && close(s[0], (0.0, 50.0)), "{s:?}"); + } + + #[test] + fn degenerate_and_hostile_paths_give_no_area() { + assert!(AreaShape::new(&Path::default(), false).is_none()); + assert!(AreaShape::new(&Path::new(vec![Subpath::polyline(&[(0.0, 0.0), (10.0, 10.0), (0.0, 10.0)])]), false).is_none(), "open only"); + assert!(AreaShape::new(&poly(&[(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)]), false).is_none(), "no height"); + assert!(AreaShape::new(&poly(&[(0.0, 0.0), (f64::NAN, 0.0), (5.0, 5.0)]), false).is_none()); + assert!(AreaShape::new(&poly(&[(0.0, 0.0), (1e300, 0.0), (5.0, 5.0)]), false).is_none(), "beyond f32"); + let mut inv = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + inv.inverted = true; + assert!(AreaShape::new(&inv, false).is_none(), "an inverted path is unbounded"); + let many: Vec<(f64, f64)> = (0..MAX_AREA_EDGES + 10).map(|i| ((i as f64).cos() * 50.0, (i as f64).sin() * 50.0)).collect(); + assert!(AreaShape::new(&poly(&many), false).is_none()); + assert!(AreaShape::new(&poly(&[(0.0, 0.0), (1e8, 0.0), (5.0, 5.0)]), false).is_none(), "beyond 1e7"); + // Bad bands never panic. + let a = AreaShape::new(&poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]), false).unwrap(); + assert!(a.spans(f32::NAN, 5.0, 1.0).is_empty()); + assert!(a.spans(5.0, 1.0, 1.0).is_empty()); + assert!(a.next_row(f32::INFINITY, 1.0, 1.0, 1.0).is_none()); + } +} diff --git a/crates/text/src/cjk.rs b/crates/text/src/cjk.rs index f0e3193baa..ab1c7d8992 100644 --- a/crates/text/src/cjk.rs +++ b/crates/text/src/cjk.rs @@ -53,28 +53,48 @@ pub fn classify(c: char) -> Option { }) } -/// Script order for a locale tag such as `ja_JP.UTF-8`, `zh-Hant-TW`, `zh_CN` or `ko-KR`. -/// Non-CJK (or unknown) locales get Simplified Chinese first, so Japanese forms only win -/// for shared Han when the locale is Japanese. -pub fn script_order(locale: Option<&str>) -> [CjkScript; 4] { +/// A locale tag reduced to what [`script_order`] and [`locale_script`] compare: +/// lowercase, `_` as `-`, without an encoding or modifier suffix. +fn normalized_locale(locale: Option<&str>) -> String { + locale.unwrap_or("").split(['.', '@']).next().unwrap_or("").to_ascii_lowercase().replace('_', "-") +} + +/// The CJK script a locale explicitly asks for, or `None` for a non-CJK (or unknown) locale. +/// +/// [`script_order`] also gives non-CJK locales a default order (Simplified Chinese first). The +/// craft-font manifest, however, lists its Japanese faces before Noto Sans CJK SC, so the eager +/// provider keeps that Japanese-first baseline for Han unless the user's locale names another +/// script. This helper distinguishes a non-CJK/unknown effective locale (use the baseline +/// manifest order) from "zh-Hans/zh-Hant/ja/ko, use this script". +pub fn locale_script(locale: Option<&str>) -> Option { use CjkScript::*; - let tag = locale.unwrap_or("").split(['.', '@']).next().unwrap_or("").to_ascii_lowercase().replace('_', "-"); + let tag = normalized_locale(locale); let mut parts = tag.split('-'); let lang = parts.next().unwrap_or(""); let rest: Vec<&str> = parts.collect(); match lang { - "ja" => [Japanese, SimplifiedChinese, TraditionalChinese, Korean], - "ko" => [Korean, TraditionalChinese, SimplifiedChinese, Japanese], + "ja" => Some(Japanese), + "ko" => Some(Korean), "zh" | "yue" => { let hant = rest.iter().any(|p| matches!(*p, "hant" | "tw" | "hk" | "mo")); let hans = rest.iter().any(|p| matches!(*p, "hans" | "cn" | "sg")); - if (hant || lang == "yue") && !hans { - [TraditionalChinese, SimplifiedChinese, Japanese, Korean] - } else { - [SimplifiedChinese, TraditionalChinese, Japanese, Korean] - } + if (hant || lang == "yue") && !hans { Some(TraditionalChinese) } else { Some(SimplifiedChinese) } } - _ => [SimplifiedChinese, TraditionalChinese, Japanese, Korean], + _ => None, + } +} + +/// Script order for a locale tag such as `ja_JP.UTF-8`, `zh-Hant-TW`, `zh_CN` or `ko-KR`. +/// Non-CJK (or unknown) locales get Simplified Chinese first for system fonts, so Japanese +/// forms only win for shared Han when the locale is Japanese (craft faces keep the manifest's +/// Japanese-first order through [`locale_script`]). +pub fn script_order(locale: Option<&str>) -> [CjkScript; 4] { + use CjkScript::*; + match locale_script(locale) { + Some(Japanese) => [Japanese, SimplifiedChinese, TraditionalChinese, Korean], + Some(Korean) => [Korean, TraditionalChinese, SimplifiedChinese, Japanese], + Some(TraditionalChinese) => [TraditionalChinese, SimplifiedChinese, Japanese, Korean], + Some(SimplifiedChinese) | None => [SimplifiedChinese, TraditionalChinese, Japanese, Korean], } } @@ -330,7 +350,10 @@ fn fontconfig_match(script: CjkScript) -> Option { fn fontconfig_font_covers_sample(bytes: &[u8], family: &str, script: CjkScript) -> bool { use skrifa::MetadataProvider; - let index = face_index_for_family(bytes, family); + let Some(index) = face_index_for_family_checked(bytes, family) else { return false }; + if !font_face_is_readable(bytes, index) { + return false; + } let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { return false }; let sample = match script { CjkScript::Japanese => "レイヤー図", @@ -509,22 +532,121 @@ pub fn mac_pingfang() -> Option { None } -/// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is -/// empty or no face matches. Never panics on malformed data. -pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 { - use skrifa::{MetadataProvider, raw::FileRef, string::StringId}; - if family.is_empty() { - return 0; - } - let Ok(FileRef::Collection(c)) = FileRef::new(bytes) else { return 0 }; - for (i, f) in c.iter().enumerate() { - let Ok(f) = f else { continue }; - let named = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME].iter().any(|id| f.localized_strings(*id).any(|s| s.chars().eq(family.chars()))); - if named { - return i as u32; +/// Maximum collection faces and cumulative `name` records inspected by +/// [`face_index_for_family_checked`]. +const MAX_COLLECTION_FACES: usize = 256; +const MAX_NAME_RECORDS: usize = 8192; + +/// Whether `font` has a family record exactly equal to `family`, charging each inspected record +/// to `budget` so malformed fonts cannot make this scan unbounded. +fn family_name_matches(font: &skrifa::FontRef<'_>, family: &str, budget: &mut usize) -> bool { + use skrifa::{MetadataProvider, string::StringId}; + + for id in [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME] { + for s in font.localized_strings(id) { + let Some(rem) = budget.checked_sub(1) else { + return false; + }; + *budget = rem; + if s.chars().eq(family.chars()) { + return true; + } } } - 0 + false +} + +/// Whether every table in the selected face of `bytes` really is present in the file. +/// +/// `FontRef` reads tables lazily: a face whose `cmap` and `name` are valid can still point another +/// table outside the file. Coverage checks only read `cmap`, so callers that register a selected +/// collection face (by family index, coverage result or face 0) must check this first; epaint +/// panics when it installs a configured face the parser cannot read. +fn face_tables_are_readable(font: &skrifa::raw::FontRef<'_>) -> bool { + let directory = font.table_directory(); + let records = directory.table_records(); + !records.is_empty() && records.len() == usize::from(directory.num_tables()) && records.iter().all(|record| font.table_data(record.tag()).is_some()) +} + +/// Whether face `index` of a font file or collection has a complete, in-bounds table directory. +/// +/// This is the selected-index counterpart of [`font_file_is_readable`]. A readable collection may +/// still contain a malformed face at `index`; the face's `cmap` and `name` can work while another +/// table points outside the file. Returns `false` for an out-of-range index or malformed data. +/// Never panics. +pub fn font_face_is_readable(bytes: &[u8], index: u32) -> bool { + skrifa::raw::FontRef::from_index(bytes, index).is_ok_and(|font| face_tables_are_readable(&font)) +} + +/// Whether `bytes` is a font file or font collection that the text engine's parser can read. +/// +/// This uses the same `skrifa` parser the text engine lays out and rasterizes with, instead of a +/// hand-written table-directory check with invented face/table caps. A readable non-font or +/// truncated file returns `false`; valid SFNT, OTF and TTC data returns `true`. For a collection, +/// at least one face must have a complete table directory. That does **not** make every face +/// readable: a face selected by family name, coverage or the empty-family face-0 contract must be +/// checked with [`font_face_is_readable`], because its `cmap` and `name` can work while another +/// table points out of bounds. +pub fn font_file_is_readable(bytes: &[u8]) -> bool { + use skrifa::raw::FileRef; + + match FileRef::new(bytes) { + Ok(FileRef::Font(font)) => face_tables_are_readable(&font), + Ok(FileRef::Collection(faces)) => faces.iter().any(|face| face.is_ok_and(|font| face_tables_are_readable(&font))), + Err(_) => false, + } +} + +/// Face index in a font file (or collection) whose family name is `family`. +/// +/// An empty `family` is the explicit "face 0" contract. A single-face file must actually carry +/// the requested family name; a collection must contain a face with it. `None` when the family is +/// missing, the data is malformed or the name budget is exhausted. The returned index has a +/// matching `name`, but its other tables are not validated: use [`font_face_is_readable`] before +/// registering it. Never panics. +pub fn face_index_for_family_checked(bytes: &[u8], family: &str) -> Option { + use skrifa::raw::{FileRef, types::Tag}; + + if family.is_empty() { + return Some(0); + } + let mut budget = MAX_NAME_RECORDS; + match FileRef::new(bytes).ok()? { + FileRef::Font(font) => family_name_matches(&font, family, &mut budget).then_some(0), + FileRef::Collection(c) => { + let mut seen_faces = std::collections::HashSet::new(); + let mut seen_names = std::collections::HashSet::new(); + for (i, f) in c.iter().take(MAX_COLLECTION_FACES).enumerate() { + if let Some(off_bytes) = bytes.get(12 + i * 4..12 + (i + 1) * 4) + && let Ok(arr) = <[u8; 4]>::try_from(off_bytes) + && !seen_faces.insert(u32::from_be_bytes(arr)) + { + continue; + } + let Ok(f) = f else { continue }; + if let Some(rec) = f.table_directory.table_records().iter().find(|r| r.tag() == Tag::new(b"name")) + && !seen_names.insert((rec.offset(), rec.length())) + { + continue; + } + if family_name_matches(&f, family, &mut budget) { + return u32::try_from(i).ok(); + } + if budget == 0 { + return None; + } + } + None + } + } +} + +/// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is +/// empty or no face matches. Kept for callers that prefer the historical face-0 fallback; new +/// system-font selection should use [`face_index_for_family_checked`], verify the selected face +/// with [`font_face_is_readable`], and then verify coverage. Never panics on malformed data. +pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 { + face_index_for_family_checked(bytes, family).unwrap_or(0) } #[cfg(test)] @@ -532,6 +654,19 @@ mod tests { use super::*; use CjkScript::*; + #[test] + fn explicit_locale_scripts_are_distinct_from_the_non_cjk_default() { + assert_eq!(locale_script(Some("ja_JP.UTF-8")), Some(Japanese)); + assert_eq!(locale_script(Some("ko-KR")), Some(Korean)); + assert_eq!(locale_script(Some("zh-Hans-CN")), Some(SimplifiedChinese)); + assert_eq!(locale_script(Some("zh_CN")), Some(SimplifiedChinese)); + assert_eq!(locale_script(Some("zh-Hant-TW")), Some(TraditionalChinese)); + assert_eq!(locale_script(Some("yue-HK")), Some(TraditionalChinese)); + for unknown in [None, Some(""), Some("en_US.UTF-8"), Some("C"), Some("de"), Some("!!@@")] { + assert_eq!(locale_script(unknown), None, "{unknown:?}"); + } + } + #[test] fn locale_orders() { assert_eq!(script_order(Some("ja_JP.UTF-8"))[0], Japanese); @@ -627,6 +762,118 @@ mod tests { } } + /// A two-face TrueType collection built at runtime from the repository's OFL-licensed + /// Inter and JetBrains Mono files. Each face directory is rewritten with offsets relative to + /// the collection, so no font fixture is committed. + fn two_face_ttc(first: &[u8], second: &[u8]) -> Vec { + let font_count = 2usize; + let tables = |font: &[u8]| u16::from_be_bytes([font[4], font[5]]) as usize; + let dir_lengths = [12 + 16 * tables(first), 12 + 16 * tables(second)]; + let mut dir_offsets = Vec::with_capacity(font_count); + let mut cursor = 12 + 4 * font_count; + for length in dir_lengths { + dir_offsets.push(cursor); + cursor += length; + } + let data_start = cursor.next_multiple_of(4); + let mut bases = Vec::with_capacity(font_count); + let mut total = data_start; + for font in [first, second] { + bases.push(total); + total += font.len().next_multiple_of(4); + } + let mut out = vec![0u8; total]; + out[..4].copy_from_slice(b"ttcf"); + out[4..8].copy_from_slice(&0x0001_0000u32.to_be_bytes()); + out[8..12].copy_from_slice(&u32::try_from(font_count).expect("two faces").to_be_bytes()); + for (i, offset) in dir_offsets.iter().enumerate() { + let at = 12 + i * 4; + out[at..at + 4].copy_from_slice(&u32::try_from(*offset).expect("collection fits").to_be_bytes()); + } + for (i, font) in [first, second].into_iter().enumerate() { + let dir = dir_offsets[i]; + let base = bases[i]; + out[dir..dir + font.len().min(12)].copy_from_slice(&font[..font.len().min(12)]); + for record in 0..tables(font) { + let src = 12 + 16 * record; + let dst = dir + 12 + 16 * record; + out[dst..dst + 8].copy_from_slice(&font[src..src + 8]); + let table_offset = u32::from_be_bytes(font[src + 8..src + 12].try_into().expect("four bytes")); + let collection_offset = u32::try_from(base).expect("collection fits") + table_offset; + out[dst + 8..dst + 12].copy_from_slice(&collection_offset.to_be_bytes()); + out[dst + 12..dst + 16].copy_from_slice(&font[src + 12..src + 16]); + } + out[base..base + font.len()].copy_from_slice(font); + } + out + } + + #[test] + fn checked_face_lookup_uses_the_requested_collection_family_and_exact_coverage() { + use crate::scripts::face_covers; + + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let jetbrains = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); + let ttc = two_face_ttc(inter, jetbrains); + + assert_eq!(face_index_for_family_checked(&ttc, "Inter"), Some(0)); + assert_eq!(face_index_for_family_checked(&ttc, "JetBrains Mono"), Some(1)); + assert_eq!(face_index_for_family_checked(&ttc, "Noto Sans CJK TC"), None); + assert_eq!(face_index_for_family(&ttc, "JetBrains Mono"), 1, "the old wrapper keeps its face-0 fallback only on a miss"); + assert_eq!(face_index_for_family_checked(inter, "Inter"), Some(0)); + assert_eq!(face_index_for_family_checked(inter, "JetBrains Mono"), None, "a single-face file must carry the requested family"); + assert_eq!(face_index_for_family_checked(inter, ""), Some(0), "empty family is the explicit face-0 contract"); + assert_eq!(face_index_for_family_checked(b"", "Inter"), None); + + // Both faces cover A. JetBrains Mono does not cover Latin Small Letter B with Stroke, + // while Inter does, so a first-covering-face lookup cannot satisfy this pair. + assert!(face_covers(&ttc, 0, 'A')); + assert!(face_covers(&ttc, 1, 'A')); + assert!(face_covers(&ttc, 0, 'ƀ')); + assert!(!face_covers(&ttc, 1, 'ƀ')); + assert!(!face_covers(&ttc, 1, '日')); + assert!(!face_covers(&ttc, 2, 'A'), "out-of-range face index"); + } + + #[test] + fn font_file_validation_uses_the_layout_parser() { + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let jetbrains = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); + assert!(font_file_is_readable(inter)); + assert!(font_file_is_readable(&two_face_ttc(inter, jetbrains))); + assert!(!font_file_is_readable(b"this is not a font")); + assert!(!font_file_is_readable(&inter[..20])); + let mut missing_table = inter.to_vec(); + // The first directory record begins at 12; its offset occupies bytes 20..24. + missing_table[20..24].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(!font_file_is_readable(&missing_table), "a complete directory cannot point outside the file"); + } + + /// A readable collection can contain a malformed non-selected face, so the file-level check + /// must not stand in for validating the face a caller selects. The selected face keeps a valid + /// `cmap` (coverage succeeds) and `name` (family lookup succeeds) while another table points + /// past the end of the file. + #[test] + fn selected_face_validation_rejects_a_malformed_face_in_a_readable_collection() { + use crate::scripts::face_covers; + + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let jetbrains = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); + let mut malformed = jetbrains.to_vec(); + let tables = u16::from_be_bytes([malformed[4], malformed[5]]) as usize; + let glyf = (0..tables).map(|i| 12 + 16 * i).find(|&rec| malformed.get(rec..rec + 4) == Some(&b"glyf"[..])).expect("JetBrains Mono has a glyf table"); + malformed[glyf + 12..glyf + 16].copy_from_slice(&u32::MAX.to_be_bytes()); + let ttc = two_face_ttc(inter, &malformed); + + assert!(font_file_is_readable(&ttc), "face 0 remains a readable face"); + assert_eq!(face_index_for_family_checked(&ttc, "JetBrains Mono"), Some(1), "the malformed face's name still resolves"); + assert!(face_covers(&ttc, 1, 'A'), "the malformed face's cmap still covers A"); + assert!(!font_face_is_readable(&ttc, 1), "its glyf table lies outside the collection"); + assert!(font_face_is_readable(&ttc, 0), "positive control: the good face stays readable"); + assert!(!font_face_is_readable(&ttc, 2), "out-of-range face"); + assert!(font_face_is_readable(&two_face_ttc(inter, jetbrains), 1), "positive control: a valid selected face passes"); + } + #[test] fn face_index_handles_bad_data() { assert_eq!(face_index_for_family(b"", "PingFang SC"), 0); diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs index c1c1949ea9..ed24a42035 100644 --- a/crates/text/src/craft_fonts.rs +++ b/crates/text/src/craft_fonts.rs @@ -1,9 +1,12 @@ //! Fonts from the optional craft-fonts build input (https://github.com/storytold/craft-fonts). //! //! `CRAFT_FONTS` is empty unless the app was built with `CRAFT_FONTS_DIR=` -//! (see `build.rs`); every user of it must work when it is empty. Today it carries the Japanese -//! fonts: BIZ UDPGothic (UI) and Shippori Mincho / BIZ UDMincho (serif document text). The web -//! build (wasm32) embeds none of them: they don't fit its size cap (see `build.rs`). +//! (see `build.rs`); every user of it must work when it is empty. It carries every font in the +//! checkout's `fonts/manifest.txt`, whatever its scripts: the Japanese fonts BIZ UDPGothic (UI) and +//! Shippori Mincho / BIZ UDMincho (serif document text) and, in newer checkouts, fonts for other +//! scripts such as Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai. +//! [`crate::fonts::FontDb::new`] registers all of them. The web build (wasm32) embeds none of +//! them: they don't fit its size cap (see `build.rs`). /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). pub struct CraftFont { diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index a68d093669..19f3af1886 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -342,6 +342,9 @@ fn format_real(r: f64) -> String { if matches!(s.as_str(), "0." | "-0.") { return "0.0".into(); } + if s.ends_with('.') { + s.push('0'); + } if let Some(rest) = s.strip_prefix("0.") { s = format!(".{rest}"); } else if let Some(rest) = s.strip_prefix("-0.") { @@ -356,6 +359,18 @@ mod tests { const SAMPLE: &[u8] = b"\n\n<<\n\t/EngineDict\n\t<<\n\t\t/Editor\n\t\t<<\n\t\t\t/Text (\xFE\xFF\x00H\x00i\x00\\)\x00\r)\n\t\t>>\n\t\t/Nums [ 1.0 .5 -2 0 ]\n\t\t/Flag true\n\t\t/Kind /Roman\n\t\t/Runs [\n\t\t<<\n\t\t\t/A 1\n\t\t>>\n\t\t]\n\t>>\n>>"; + #[test] + fn format_real_never_ends_with_bare_dot() { + assert_eq!(format_real(5.000001), "5.0"); + assert_eq!(format_real(12.000004), "12.0"); + assert_eq!(format_real(-5.000001), "-5.0"); + assert_eq!(format_real(1e20), "100000000000000000000.0"); + assert_eq!(format_real(0.5), ".5"); + assert_eq!(format_real(-0.25), "-.25"); + assert_eq!(format_real(1e-9), "0.0"); + assert_eq!(format_real(3.0), "3.0"); + } + #[test] fn parses_sample() { let v = parse(SAMPLE).unwrap(); diff --git a/crates/text/src/font_names_tests.rs b/crates/text/src/font_names_tests.rs index 0de4bf2445..a1eda9b801 100644 --- a/crates/text/src/font_names_tests.rs +++ b/crates/text/src/font_names_tests.rs @@ -124,7 +124,7 @@ fn collection_isolation_preserves_requested_face() { let blob = Blob::new(Arc::new(bytes)); assert!(isolated_face(blob.clone(), 2).is_none()); let mut db = FontDb::new(); - db.register_blob(blob.clone()); + db.register_blob(blob.clone(), None); let mut style = CharStyle { font_family: "Numeric Test".into(), font_style: "30".into(), ..Default::default() }; db.select_named_face(&mut style); assert_eq!(db.faces(&style.font_family)[0].postscript_name.as_deref(), Some("Collection30")); @@ -188,3 +188,283 @@ fn localized_family_names_map_english_families_to_native_names() { assert!(!local.contains_key(DEFAULT_FAMILY)); assert!(local.values().all(|v| !v.is_empty())); } + +/// A craft-fonts manifest entry for `bytes`, as `build.rs` writes one per manifest line. +fn craft(family: &'static str, scripts: &'static [&'static str], bytes: Vec) -> crate::CraftFont { + crate::CraftFont { family, style: "Regular", scripts, bytes: bytes.leak() } +} + +/// A TrueType collection of single-face fonts. +fn collection(faces: &[Vec]) -> Vec { + let mut bytes = b"ttcf\0\x01\0\0".to_vec(); + bytes.extend((faces.len() as u32).to_be_bytes()); + let mut start = 12 + 4 * faces.len(); + for face in faces { + bytes.extend((start as u32).to_be_bytes()); + start += face.len(); + } + for face in faces { + let start = bytes.len(); + bytes.extend(face); + let count = u16::from_be_bytes(bytes[start + 4..start + 6].try_into().unwrap()) as usize; + for i in 0..count { + let r = start + 12 + i * 16 + 8; + let offset = u32::from_be_bytes(bytes[r..r + 4].try_into().unwrap()) + start as u32; + bytes[r..r + 4].copy_from_slice(&offset.to_be_bytes()); + } + } + bytes +} + +/// Single-face craft fonts of every script register under their manifest family names, whatever +/// family their `name` tables give, and the ones the fallback lists name are in the fallback +/// stack. The Hans and Arab families are fallback-list names, and no craft-fonts manifest uses +/// any of the three, so `FontDb::new` does not register them from the build's own craft fonts. +#[test] +fn craft_fonts_of_every_script_register_under_their_manifest_families() { + let fonts = [ + craft("WenQuanYi Micro Hei", &["Hans", "Latn"], localized_font(&INTER_REGULAR, "WenQuanYi Micro Hei", "WenQuanYi Micro Hei")), + // The `name` tables of these two give the family "Numeric Test". + craft("Geeza Pro", &["Arab"], numeric_font(&INTER_REGULAR, "20", "CraftTestArabic")), + craft("Craft Test Gothic", &["Jpan", "Latn"], numeric_font(&INTER_SEMIBOLD, "30", "CraftTestGothic")), + ]; + let mut db = FontDb::new(); + assert!(fonts.iter().all(|f| !db.has_family(f.family))); + db.register_craft_fonts(&fonts); + let families = db.families(); + for f in &fonts { + assert!(families.iter().any(|n| n == f.family), "{} registered under its manifest family: {families:?}", f.family); + } + assert!(!db.has_family("Numeric Test"), "{families:?}"); + for family in ["WenQuanYi Micro Hei", "Geeza Pro"] { + assert!(db.fallback_stack().any(|f| f == family), "{family} in {:?}", db.fallback_stack().collect::>()); + } +} + +/// Each face of a craft font collection keeps the family its `name` table gives. +#[test] +fn craft_font_collections_keep_their_face_families() { + let faces = [localized_font(&INTER_REGULAR, "Craft Test A", "クラフト A"), localized_font(&INTER_SEMIBOLD, "Craft Test B", "クラフト B")]; + let fonts = [craft("Craft Test Collection", &["Jpan", "Latn"], collection(&faces))]; + let mut db = FontDb::new(); + db.register_craft_fonts(&fonts); + assert!(db.has_family("Craft Test A") && db.has_family("Craft Test B"), "{:?}", db.families()); + assert!(!db.has_family("Craft Test Collection")); +} + +/// A 16.16 fixed-point number as `fvar` stores it. +fn fixed(v: f32) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +/// `source` rebuilt as a variable font with `wght` 100–900 and `wdth` 75–100 axes and two named +/// instances, without redistributing a variable font (#8797): "Light Condensed" (300, 75) has no +/// PostScript name of its own, so its name is derived as `InstanceTest-LightCondensed`; +/// "Heavy" (900, 100) names itself `InstanceTest-Black`. Outlines do not vary, but an `HVAR` table +/// (see [`width_hvar`]) narrows every advance on `wdth`, so layout width shows which instance drew. +fn instance_font(source: &[u8]) -> Vec { + let names = [ + (1u16, "Instance Test"), + (2, "Regular"), + (6, "InstanceTest-Regular"), + (16, "Instance Test"), + (256, "Weight"), + (257, "Width"), + (258, "Light Condensed"), + (259, "InstanceTest-Black"), + (260, "Heavy"), + ]; + let mut name = Vec::new(); + let mut strings = Vec::new(); + for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] { + name.extend(value.to_be_bytes()); + } + for (id, value) in names { + let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for n in [3, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] { + name.extend(n.to_be_bytes()); + } + strings.extend(encoded); + } + name.extend(strings); + // Two axes of 20 bytes, then two instances of 4 + 2 * 4 + 2 bytes (with PostScript name IDs). + let mut fvar = Vec::new(); + for n in [1u16, 0, 16, 2, 2, 20, 2, 14] { + fvar.extend(n.to_be_bytes()); + } + for (tag, min, default, max, name_id) in [(b"wght", 100.0, 400.0, 900.0, 256u16), (b"wdth", 75.0, 100.0, 100.0, 257)] { + fvar.extend(tag); + for v in [min, default, max] { + fvar.extend(fixed(v)); + } + fvar.extend([0, 0]); + fvar.extend(name_id.to_be_bytes()); + } + for (subfamily, coords, ps) in [(258u16, [300.0, 75.0], 0xFFFFu16), (260, [900.0, 100.0], 259)] { + fvar.extend(subfamily.to_be_bytes()); + fvar.extend([0, 0]); + for v in coords { + fvar.extend(fixed(v)); + } + fvar.extend(ps.to_be_bytes()); + } + // Rebuild the table directory: every table but `name` as it was, plus the new `name`, `fvar` + // and `HVAR`. + let count = u16::from_be_bytes(source[4..6].try_into().unwrap()) as usize; + let mut tables: Vec<([u8; 4], Vec)> = (0..count) + .map(|i| { + let r = 12 + i * 16; + let at = u32::from_be_bytes(source[r + 8..r + 12].try_into().unwrap()) as usize; + let len = u32::from_be_bytes(source[r + 12..r + 16].try_into().unwrap()) as usize; + (source[r..r + 4].try_into().unwrap(), source[at..at + len].to_vec()) + }) + .filter(|(tag, _)| tag != b"name") + .collect(); + let table = |tag: &[u8; 4]| tables.iter().find(|(t, _)| t == tag).map(|(_, data)| data.as_slice()).unwrap(); + let hvar = width_hvar(table(b"hhea"), table(b"hmtx"), table(b"maxp")); + tables.push((*b"name", name)); + tables.push((*b"fvar", fvar)); + tables.push((*b"HVAR", hvar)); + tables.sort_by_key(|(tag, _)| *tag); + let mut out = source[0..4].to_vec(); + for n in [tables.len() as u16, 0, 0, 0] { + out.extend(n.to_be_bytes()); + } + let mut at = 12 + 16 * tables.len(); + for (tag, data) in &tables { + out.extend(tag); + out.extend(0u32.to_be_bytes()); + out.extend((at as u32).to_be_bytes()); + out.extend((data.len() as u32).to_be_bytes()); + at += data.len().next_multiple_of(4); + } + for (_, data) in &tables { + out.extend(data); + out.resize(out.len().next_multiple_of(4), 0); + } + out +} + +/// An `HVAR` table for [`instance_font`]'s axes (`wght`, then `wdth`) that takes 30% off every +/// glyph's advance at `wdth` 75 and nothing at the default 100, scaled linearly between. Advances +/// come from `hmtx`; glyph IDs map to deltas directly, as no advance mapping is given. +fn width_hvar(hhea: &[u8], hmtx: &[u8], maxp: &[u8]) -> Vec { + let glyphs = u16::from_be_bytes(maxp[4..6].try_into().unwrap()); + let metrics = u16::from_be_bytes(hhea[34..36].try_into().unwrap()) as usize; + let advance = |gid: usize| { + let at = 4 * gid.min(metrics - 1); + i32::from(u16::from_be_bytes(hmtx[at..at + 2].try_into().unwrap())) + }; + let mut out = Vec::new(); + // Header: version 1.0, item variation store at 20, no advance, LSB or RSB mappings. + for n in [1u16, 0] { + out.extend(n.to_be_bytes()); + } + for n in [20u32, 0, 0, 0] { + out.extend(n.to_be_bytes()); + } + // Item variation store: format 1, region list at 12, one item variation data at 28. + out.extend(1u16.to_be_bytes()); + out.extend(12u32.to_be_bytes()); + out.extend(1u16.to_be_bytes()); + out.extend(28u32.to_be_bytes()); + // One region over two axes as (start, peak, end) in F2Dot14: `wght` unused, `wdth` -1..0 + // peaking at -1 (75). + for n in [2u16, 1, 0, 0, 0, 0xC000, 0xC000, 0] { + out.extend(n.to_be_bytes()); + } + // Item variation data: one 16-bit delta per glyph for region 0. + for n in [glyphs, 1, 1, 0] { + out.extend(n.to_be_bytes()); + } + for gid in 0..glyphs as usize { + out.extend((-(advance(gid) * 3 / 10) as i16).to_be_bytes()); + } + out +} + +/// The axis values `select_named_face` gives a style of the synthesized variable family that +/// carries the PostScript name `ps`, sorted by axis. +fn instance_axes(db: &mut FontDb, ps: &str, variations: Vec) -> Vec<(String, f32)> { + let mut style = CharStyle { font_family: "Instance Test".into(), postscript_name: Some(ps.into()), weight: 300, variations, ..Default::default() }; + db.select_named_face(&mut style); + let mut axes: Vec<_> = style.variations.into_iter().map(|v| (v.axis, v.value)).collect(); + axes.sort_by(|a, b| a.0.cmp(&b.0)); + axes +} + +/// #8797: a PostScript name that names a named instance of a variable font (as a PSD stores +/// "Bahnschrift-LightCondensed") selects that instance's axis values, whether the instance names +/// itself or its name is derived from the family and subfamily names. A name no instance has +/// leaves the style at the default instance, and axes the style sets itself are kept. +#[test] +fn postscript_name_of_a_named_instance_selects_its_axis_values() { + use photocraft_doc::text::FontVariation; + let mut db = FontDb::new(); + db.register_font_data(instance_font(&INTER_REGULAR)); + assert_eq!(db.faces("Instance Test").len(), 1, "a variable family is one face"); + let axes = |pairs: &[(&str, f32)]| pairs.iter().map(|(a, v)| (a.to_string(), *v)).collect::>(); + assert_eq!(instance_axes(&mut db, "InstanceTest-LightCondensed", vec![]), axes(&[("wdth", 75.0), ("wght", 300.0)])); + assert_eq!(instance_axes(&mut db, "InstanceTest-Black", vec![]), axes(&[("wdth", 100.0), ("wght", 900.0)])); + assert_eq!(instance_axes(&mut db, "InstanceTest-Missing", vec![]), axes(&[])); + let own = vec![FontVariation { axis: "wght".into(), value: 350.0 }]; + assert_eq!(instance_axes(&mut db, "InstanceTest-LightCondensed", own), axes(&[("wdth", 75.0), ("wght", 350.0)])); +} + +/// A static face whose PostScript name matches exactly is selected as before (#8797 only adds +/// a fallback for names no face has), and gets no axis values. +#[test] +fn exact_static_face_gets_no_instance_axes() { + let mut db = FontDb::new(); + db.register_font_data(numeric_font(&INTER_SEMIBOLD, "30", "StaticPS30")); + let mut style = CharStyle { font_family: "Numeric Test".into(), postscript_name: Some("StaticPS30".into()), ..Default::default() }; + db.select_named_face(&mut style); + assert_ne!(style.font_family, "Numeric Test", "the exact face is selected through its alias"); + assert!(style.variations.is_empty(), "{:?}", style.variations); +} + +/// A style whose `font_style` and weight also match the variable face itself ("Regular", 400) still +/// gets the named instance its PostScript name gives: the instance is tried before the +/// style-and-weight fallback, which would select the face at its default instance (#8797). +#[test] +fn named_instance_wins_over_a_matching_font_style() { + let mut db = FontDb::new(); + db.register_font_data(instance_font(&INTER_REGULAR)); + let mut style = CharStyle { + font_family: "Instance Test".into(), + font_style: "Regular".into(), + weight: 400, + postscript_name: Some("InstanceTest-LightCondensed".into()), + ..Default::default() + }; + db.select_named_face(&mut style); + assert_ne!(style.font_family, "Instance Test", "the variable face is selected through its alias"); + let mut axes: Vec<_> = style.variations.into_iter().map(|v| (v.axis, v.value)).collect(); + axes.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(axes, vec![("wdth".to_string(), 75.0), ("wght".to_string(), 300.0)]); +} + +/// #8797 end to end without an installed font: text whose style carries a named instance's +/// PostScript name lays out as wide as that instance's axis values, which the synthesized `HVAR` +/// makes narrower than the default instance. The style's `font_style` and weight match the +/// variable face itself, so the default instance would be chosen if the instance were skipped. +#[test] +fn postscript_name_of_a_named_instance_lays_out_at_that_instance() { + use photocraft_doc::TextLayer; + use photocraft_doc::text::{FontVariation, TextRun}; + let mut engine = crate::TextEngine::new(); + engine.fonts.register_font_data(instance_font(&INTER_REGULAR)); + let text = "Hamburgefonstiv 0123"; + let mut width = |style: CharStyle| { + let layer = TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() }; + let l = engine.layout(&layer, 96.0); + l.lines[0].x1 - l.lines[0].x0 + }; + let base = CharStyle { font_family: "Instance Test".into(), font_style: "Regular".into(), size_pt: 72.0, weight: 400, ..Default::default() }; + let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }]; + let family = width(base.clone()); + let instance = width(CharStyle { postscript_name: Some("InstanceTest-LightCondensed".into()), ..base.clone() }); + let explicit = width(CharStyle { variations: axes, ..base }); + assert!(explicit < family * 0.8, "wdth 75 is narrower: {explicit} vs {family}"); + assert!((instance - explicit).abs() < 0.5, "instance: {instance} px, its axes: {explicit} px, default instance: {family} px"); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 720d2b77eb..a0b64f4e3b 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1,6 +1,6 @@ //! Font database: bundled fonts (always available, also on the web), the optional craft-fonts -//! Japanese fonts ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional system -//! fonts found by scanning platform directories (plus Fontconfig's file list on Linux), +//! fonts of every script ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional +//! system fonts found by scanning platform directories (plus Fontconfig's file list on Linux), //! user-registered font data, and PostScript-name lookup for PSD import. use std::collections::HashMap; @@ -225,14 +225,22 @@ impl FontDb { db.register_static_font(bytes.as_slice()); } // The optional craft-fonts (empty unless built with CRAFT_FONTS_DIR; always empty on - // wasm32), before any system font. - for f in crate::craft_fonts::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) { - db.register_static_font(f.bytes); - } + // wasm32), every script, before any system font. + db.register_craft_fonts(crate::craft_fonts::CRAFT_FONTS); db.refresh_generics(); db } + /// Registers craft-fonts manifest entries of every script. A single-face file registers under + /// its manifest family, whatever family its `name` table gives. Each face of a collection + /// (TTC/OTC) keeps the family its `name` table gives. + fn register_craft_fonts(&mut self, fonts: &[crate::CraftFont]) { + for f in fonts { + let family = (face_count(f.bytes) == 1).then_some(f.family); + self.register_blob(Blob::new(Arc::new(f.bytes)), family); + } + } + /// Bundled fonts plus the fonts installed on this machine (no-op on the web). pub fn with_system_fonts() -> Self { let mut db = Self::new(); @@ -279,16 +287,21 @@ impl FontDb { /// Registers font data (TTF/OTF, or every face of a TTC/OTC). Returns the family names added. pub fn register_font_data(&mut self, bytes: Vec) -> Vec { - self.register_blob(Blob::new(Arc::new(bytes))) + self.register_blob(Blob::new(Arc::new(bytes)), None) } /// Registers embedded font data without copying it. fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec { - self.register_blob(Blob::new(Arc::new(bytes))) + self.register_blob(Blob::new(Arc::new(bytes)), None) } - fn register_blob(&mut self, blob: Blob) -> Vec { - let added = self.fcx.collection.register_fonts(blob, None); + /// Registers every face in `blob`, under `family` when given, else under the family each + /// face's `name` table gives. + fn register_blob(&mut self, blob: Blob, family: Option<&str>) -> Vec { + let Some(blob) = sanitize_font_blob(blob) else { + return Vec::new(); + }; + let added = self.fcx.collection.register_fonts(blob, family.map(|f| FontInfoOverride { family_name: Some(f), ..Default::default() })); let mut names = Vec::new(); for (id, _) in added { if let Some(n) = self.fcx.collection.family_name(id) @@ -470,10 +483,29 @@ impl FontDb { /// Select an exact face for shaping. Private aliases are runtime-only: the document keeps /// its public family and PostScript name. CSS matching alone cannot distinguish e.g. the /// numeric subfamilies of Yoon fonts, all of which declare weight 400 / normal. + /// + /// A PostScript name that no face has may name an instance of a variable face + /// ("Bahnschrift-LightCondensed"): that face is selected and the instance's axis values are + /// added to the style's variations, keeping any axis the style sets itself (#8797). Faces are + /// tried in this order: exact PostScript name, named instance, then style, weight and italic. pub(crate) fn select_named_face(&mut self, style: &mut photocraft_doc::text::CharStyle) { let family = style.font_family.clone(); let faces = self.faces(&family); - let index = style.postscript_name.as_ref().and_then(|ps| faces.iter().position(|f| f.postscript_name.as_ref() == Some(ps))).or_else(|| { + let index = style.postscript_name.as_ref().and_then(|ps| faces.iter().position(|f| f.postscript_name.as_ref() == Some(ps))); + // The named instance is tried before the style-and-weight fallback: a variable face's own + // style ("Regular") can match a populated `font_style` and would drop the instance's axes. + let instance = match (index, &style.postscript_name) { + (None, Some(ps)) => self.named_instance(&family, &faces, ps), + _ => None, + }; + if let Some((_, axes)) = &instance { + for (axis, value) in axes { + if !style.variations.iter().any(|v| &v.axis == axis) { + style.variations.push(photocraft_doc::text::FontVariation { axis: axis.clone(), value: *value }); + } + } + } + let index = index.or(instance.map(|(i, _)| i)).or_else(|| { faces.iter().position(|f| { !style.font_style.is_empty() && f.style.eq_ignore_ascii_case(&style.font_style) @@ -501,6 +533,44 @@ impl FontDb { style.italic = face.italic; } + /// The variable face of `family` (index into `faces`) with a named instance whose PostScript + /// name is `ps`, and that instance's axis values. An instance without a PostScript name of its + /// own is named as Adobe Technical Note #5902 derives it: the variations prefix (name 25, else + /// the family name) and the subfamily name without spaces, joined by a hyphen. Names compare + /// without punctuation or case, and the subfamily alone matches the part of `ps` after the + /// first hyphen, since the prefix a document stored can differ from the font's. + fn named_instance(&mut self, family: &str, faces: &[FaceInfo], ps: &str) -> Option<(usize, Vec<(String, f32)>)> { + let key = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect::(); + let wanted = key(ps); + let wanted_style = ps.split_once('-').map(|(_, s)| key(s)); + let info = self.fcx.collection.family_by_name(family)?; + for (index, face) in info.fonts().iter().enumerate() { + if faces.get(index).is_none_or(|f| f.axes.is_empty()) { + continue; + } + let Some(blob) = face.load(Some(&mut self.fcx.source_cache)) else { continue }; + let Ok(font) = skrifa::FontRef::from_index(blob.as_ref(), face.index()) else { continue }; + let name = |id: StringId| font.localized_strings(id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()); + let prefix = name(StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX) + .or_else(|| name(StringId::TYPOGRAPHIC_FAMILY_NAME)) + .or_else(|| name(StringId::FAMILY_NAME)) + .unwrap_or_default(); + for instance in font.named_instances().iter() { + let subfamily = name(instance.subfamily_name_id()).unwrap_or_default(); + let own = instance.postscript_name_id().and_then(name); + let matches = match own { + Some(own) => key(&own) == wanted, + None => key(&prefix) + &key(&subfamily) == wanted || (!subfamily.is_empty() && wanted_style.as_deref() == Some(key(&subfamily).as_str())), + }; + if matches { + let axes = font.axes().iter().map(|a| a.tag().to_string()).zip(instance.user_coords()).collect(); + return Some((index, axes)); + } + } + } + None + } + /// Finds a face by PostScript name (as stored in PSD files): exact match by reading the /// `name` table of candidate faces, else a heuristic split (`Arial-BoldMT` → Arial, 700). pub fn resolve_postscript(&mut self, ps: &str) -> ResolvedFont { @@ -541,6 +611,65 @@ impl FontDb { } } +/// Maximum faces and cumulative variation axes admitted from a single font blob. +const MAX_COLLECTION_FACES: usize = 256; +const MAX_COLLECTION_AXES: usize = 4096; + +/// Validates `blob` before handing it to `fontique`, deduplicating repeated TTC table-directory +/// offsets and rejecting collections with excessive faces or variation axes. +fn sanitize_font_blob(blob: Blob) -> Option> { + let data = blob.as_ref(); + if data.len() > 256 * 1024 * 1024 { + return None; + } + match FileRef::new(data).ok()? { + FileRef::Font(font) => { + if font.axes().len() > MAX_COLLECTION_AXES { + return None; + } + Some(blob) + } + FileRef::Collection(collection) => { + let n = usize::try_from(collection.len()).ok()?; + if n == 0 || n > MAX_COLLECTION_FACES { + return None; + } + let dir_end = 12usize.checked_add(n.checked_mul(4)?)?; + let offsets_raw = data.get(12..dir_end)?; + let mut unique_offsets: Vec = Vec::with_capacity(n); + let mut total_axes = 0usize; + for (i, chunk) in offsets_raw.as_chunks::<4>().0.iter().enumerate() { + let off = u32::from_be_bytes(*chunk); + if unique_offsets.contains(&off) { + continue; + } + let idx = u32::try_from(i).ok()?; + let Ok(font) = collection.get(idx) else { + continue; + }; + total_axes = total_axes.checked_add(font.axes().len())?; + if total_axes > MAX_COLLECTION_AXES { + return None; + } + unique_offsets.push(off); + } + if unique_offsets.is_empty() { + return None; + } + if unique_offsets.len() == n { + return Some(blob); + } + let mut bytes = data.to_vec(); + bytes.get_mut(8..12)?.copy_from_slice(&(unique_offsets.len() as u32).to_be_bytes()); + for (slot, off) in unique_offsets.iter().enumerate() { + let pos = 12 + slot * 4; + bytes.get_mut(pos..pos + 4)?.copy_from_slice(&off.to_be_bytes()); + } + Some(Blob::new(Arc::new(bytes))) + } + } +} + /// Make a collection expose only the requested face without rewriting any font tables. /// TTC table offsets are absolute; changing its face directory preserves them verbatim. fn isolated_face(blob: Blob, index: u32) -> Option> { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 8ca0b6c2aa..4936b33c63 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -22,7 +22,7 @@ use parley::{ PositionedLayoutItem, StyleProperty, }; use photocraft_doc::TextLayer; -use photocraft_doc::text::{Caps, CharStyle, FontBaseline, Kerning, Orientation, TextAlign, TextDirection, TextShape}; +use photocraft_doc::text::{Caps, CharStyle, FontBaseline, Kerning, Orientation, ParagraphStyle, TextAlign, TextDirection, TextShape}; use skrifa::raw::types::Tag; use crate::fonts::FontDb; @@ -522,9 +522,18 @@ impl Layouter { let text = &t.text; let (box_rect, is_box) = match t.shape { TextShape::Box { x, y, width, height } => ((x, y, width, height), true), - // Path text is laid out as one point-text line, then bent onto the path. - TextShape::Point | TextShape::Path { .. } => ((0.0, 0.0, 0.0, 0.0), false), + // Path text is laid out as one point-text line, then bent onto the path. Area text + // places its own lines (`area` below). + TextShape::Point | TextShape::Path { .. } | TextShape::Area { .. } => ((0.0, 0.0, 0.0, 0.0), false), }; + // Area type: the shape its lines wrap inside. A path that encloses nothing (only from a + // hostile file; `type.fillPath` refuses one) lays out as point text. + let area = match &t.shape { + TextShape::Area { path } => crate::area::AreaShape::new(path, vertical), + _ => None, + }; + // Box and area text wrap, align within their lines and hide what doesn't fit. + let wraps = is_box || area.is_some(); let mut prev_baseline: Option = None; let mut pending_space = 0.0f32; let mut stop = false; @@ -597,12 +606,13 @@ impl Layouter { // fetches it (`served`); it joins the fallback stack once it arrives. crate::served::request_for_text(&ptext); let fallback: Vec = fonts.fallback_stack().map(str::to_string).collect(); + let si0 = style_at(prange.start); + let si_end = run_starts.partition_point(|&rs| rs < content_end).max(si0 + 1).min(out.styles.len()); // Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px). let build = |lcx: &mut LayoutContext, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout { let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false); // Paragraph-start style as the default (covers the direction mark and empty // paragraphs), then every run piece intersecting this paragraph. - let si0 = style_at(prange.start); for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) { b.push_default(p); } @@ -612,7 +622,7 @@ impl Layouter { if is_box { b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord)); } - for (ri, st) in out.styles.iter().enumerate() { + for (ri, st) in out.styles.iter().enumerate().take(si_end).skip(si0) { let rs = run_starts[ri]; let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end)); if a >= z { @@ -672,11 +682,10 @@ impl Layouter { let (line_origin, line_len) = if vertical { (box_rect.1, box_rect.3) } else { (box_rect.0, box_rect.2) }; let avail = if is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None }; // Horizontal scale is applied after shaping, so paragraph text breaks at the box - // width divided by it (#5565). Mixed scales break at the largest, so no line - // overflows the box. - let break_scale = if is_box { - let si0 = style_at(prange.start); - (0..out.styles.len()) + // (or area span) width divided by it (#5565). Mixed scales break at the largest, so + // no line overflows the box. + let break_scale = if wraps { + (si0..si_end) .filter(|&ri| { let rs = run_starts.get(ri).copied().unwrap_or(0); let end = rs.saturating_add(runs.get(ri).map_or(0, |r| r.len)); @@ -689,35 +698,56 @@ impl Layouter { 1.0 }; let break_scale = if break_scale > 0.0 { break_scale } else { 1.0 }; - let break_lines = |layout: &mut Layout| { + // Area type: the first row goes no higher than one leading (plus paragraph spacing) + // below the previous paragraph's last line. + let space_before = if prev_baseline.is_some() { ps.space_before_pt * k } else { 0.0 }; + let area_from = |m: LineMetrics| match (&area, prev_baseline) { + (Some(_), Some(b)) => b + m.leading + pending_space + space_before, + (Some(a), None) => a.bounds[1] + m.ascent, + (None, _) => 0.0, + }; + // Line-breaking passes left for this paragraph's area type (see `break_area`). + let area_budget = std::cell::Cell::new(MAX_AREA_PASSES); + let break_lines = |layout: &mut Layout| -> (Vec, bool) { if first_indent != 0.0 { layout.set_text_indent(first_indent / break_scale, IndentOptions::default()); } - layout.break_all_lines(avail.map(|a| a / break_scale)); + let Some(a) = &area else { + layout.break_all_lines(avail.map(|a| a / break_scale)); + return (Vec::new(), false); + }; + // Plan every row with the paragraph's real line metrics (its tallest font and + // largest leading, measured on the unwrapped paragraph), so no wrapped line comes + // out taller than the row it was planned in and no second plan is needed. + layout.break_all_lines(None); + let m = area_metrics(layout, &out.styles, &ps, k, first_style, vertical); + break_area(layout, a, area_from(m), m, (indent_start, indent_end), first_px, break_scale, &area_budget) }; // Paragraph text: a tab is never wider than a space plus one tab interval, so breaking // lines at that width keeps the expanded tabs inside the box, but can wrap early. // Break again with the tab widths that layout gives until they agree; if they don't // settle, keep the safe layout. - let wide = if is_box { tab_px } else { 0.0 }; + let wide = if wraps { tab_px } else { 0.0 }; let mut layout = build(&mut self.lcx, &mut fonts.fcx, &vec![wide; tabs.len()]); - break_lines(&mut layout); - if is_box && !tabs.is_empty() { + let (mut area_slots, mut area_full) = break_lines(&mut layout); + if wraps && !tabs.is_empty() { let first = tab_widths(&layout, &tabs, &out.styles, indent_start, first_indent, tab_px); 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 - tab_px)).collect(); let mut next = build(&mut self.lcx, &mut fonts.fcx, &spacing); - break_lines(&mut next); + let (next_slots, next_full) = break_lines(&mut next); let got: Vec = tab_widths(&next, &tabs, &out.styles, indent_start, first_indent, tab_px).iter().map(|t| t.width).collect(); if got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 0.01) { layout = next; + area_slots = next_slots; + area_full = next_full; break; } want = got; } } - let alignment = if is_box { + let alignment = if wraps { match ps.align { TextAlign::Left => Alignment::Left, TextAlign::Center => Alignment::Center, @@ -727,7 +757,7 @@ impl Layouter { } else { Alignment::Left }; - layout.align(alignment, AlignmentOptions { align_when_overflowing: !is_box }); + layout.align(alignment, AlignmentOptions { align_when_overflowing: !wraps }); // Stack lines. if prev_baseline.is_some() { @@ -735,44 +765,24 @@ impl Layouter { } let nlines = layout.len(); for (li, line) in layout.lines().enumerate() { + // Area type: the slot the breaker gave this line. + let slot = area_slots.get(li).copied(); + if area.is_some() && slot.is_none() { + stop = true; + break; + } let m = *line.metrics(); - // Largest leading among the glyph runs of the line. - let mut leading = 0.0f32; - for item in line.items() { - if let PositionedLayoutItem::GlyphRun(gr) = item { - let st = &out.styles[gr.style().brush.0 as usize]; - let px = gr.run().font_size(); - leading = leading.max(st.leading_pt.map_or(ps.auto_leading * px, |l| l * k)); - } - } - if leading == 0.0 { - let st = &out.styles[style_at(prange.start)]; - leading = st.leading_pt.map_or(ps.auto_leading * st.size_pt * k, |l| l * k); - } - // Vertical type: half the column width (the largest em on the column). - let col_half = line - .items() - .filter_map(|it| match it { - PositionedLayoutItem::GlyphRun(gr) => Some(gr.run().font_size() * 0.5), - _ => None, - }) - .fold(0.0f32, f32::max); - let col_half = if col_half > 0.0 { col_half } else { first_px * 0.5 }; - let (ascent, descent) = if vertical { - (col_half, col_half) - } else if m.ascent > 0.0 || m.descent > 0.0 { - (m.ascent, m.descent) - } else { - (first_px * 0.8, first_px * 0.2) - }; - let baseline = match prev_baseline { + let LineMetrics { ascent, descent, leading } = line_metrics(&line, &out.styles, &ps, k, first_style, vertical); + let baseline = match (slot, prev_baseline) { + (Some(s), _) => s.baseline, // The first column's right edge touches the box's right edge. - None if is_box && vertical => -(box_rect.0 + box_rect.2) + col_half, + // (In vertical type the ascent is half the column width.) + (None, None) if is_box && vertical => -(box_rect.0 + box_rect.2) + ascent, // Photoshop's "first baseline: ascent": the top of the tallest ascender // (height of 'd') touches the box top, not the font's hhea ascent. - None if is_box => box_rect.1 + first_ascent(&line).unwrap_or(ascent), - None => 0.0, - Some(b) => b + leading + pending_space, + (None, None) if is_box => box_rect.1 + first_ascent(&line).unwrap_or(ascent), + (None, None) => 0.0, + (None, Some(b)) => b + leading + pending_space, }; pending_space = 0.0; // Photoshop keeps a horizontal line whose baseline is inside the box and lets @@ -902,7 +912,7 @@ impl Layouter { cursors.push((run.index(), g2s, 0, base)); } // Parley aligned box lines without the kerning. - let kern_align = if is_box { + let kern_align = if wraps { match ps.align { TextAlign::Center => line_kern / 2.0, TextAlign::Right => line_kern, @@ -911,9 +921,13 @@ impl Layouter { } else { 0.0 }; - let dx = if is_box { - let base = line_origin + indent_start - kern_align; - let slack = avail.unwrap_or(0.0) - adv * break_scale - line_kern; + // Box and area lines start at their box or span edge; the line's own width + // (`line_avail`) takes the justify-last-line slack. + let line_avail = slot.map(|s| s.width).or(avail); + let dx = if wraps { + let origin = slot.map_or(line_origin, |s| s.x); + let base = origin + indent_start - kern_align; + let slack = line_avail.unwrap_or(0.0) - adv * break_scale - line_kern; // Parley aligned the unscaled line in `avail / break_scale`: scale its // indent and alignment offset back to the box. let scaled_offset = m.offset * (break_scale - 1.0); @@ -934,7 +948,7 @@ impl Layouter { }; target - m.offset }; - let justify_all = is_box && last_line && ps.align == TextAlign::JustifyAll; + let justify_all = wraps && last_line && ps.align == TextAlign::JustifyAll; let line_index = out.lines.len(); let lr = line.text_range(); let g0 = out.glyphs.len(); @@ -1054,7 +1068,7 @@ impl Layouter { extra -= squeeze_punctuation(text, &mut out.clusters[c0..], &mut out.glyphs[g0..]); } if justify_all { - let slack = avail.unwrap_or(0.0) - (adv + extra); + let slack = line_avail.unwrap_or(0.0) - (adv + extra); let n = out.clusters.len() - c0; if n > 1 && slack > 0.0 { let step = slack / (n - 1) as f32; @@ -1095,6 +1109,10 @@ impl Layouter { paragraph: pi, }); } + if area_full { + // The shape is full: later paragraphs are hidden too. + stop = true; + } pending_space += ps.space_after_pt * k; } out @@ -1111,6 +1129,246 @@ fn next_tab_stop(pos: f32, interval: f32) -> f32 { if interval > 0.0 && pos.is_finite() { ((pos + 1e-3) / interval).floor() * interval + interval } else { pos } } +/// Where one line of area type goes: its baseline, the left edge of its span, and the width +/// it may fill (the span less the paragraph's indents), plus the row and span it came from. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Slot { + baseline: f32, + x: f32, + width: f32, + row: usize, + span: usize, +} + +/// A line's ascent, descent and leading (px); for area type, the largest over a paragraph. +#[derive(Clone, Copy, Debug)] +struct LineMetrics { + ascent: f32, + descent: f32, + leading: f32, +} + +/// Most line-breaking passes area type makes for one paragraph, over all its plans (each span +/// left empty for a word that doesn't fit costs a pass). Past it, words overflow their spans. +const MAX_AREA_PASSES: usize = 32; +/// Most rows one area paragraph is planned on. +const MAX_AREA_ROWS: usize = 4096; + +#[cfg(test)] +thread_local! { + /// Area line-breaking passes made on this thread, for tests. + pub(crate) static AREA_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The rows an area paragraph's lines go on: baselines from `next` down, one leading apart, +/// each with its spans left to right. They don't depend on the text, so they are found once +/// (lazily) and shared by every pass. +struct AreaRows<'a> { + area: &'a crate::area::AreaShape, + m: LineMetrics, + min_width: f32, + /// Where the search for the next row starts. + next: f32, + rows: Vec<(f32, Vec<(f32, f32)>)>, + done: bool, +} + +impl AreaRows<'_> { + /// Row `i`: its baseline and spans; `None` past the shape. + fn get(&mut self, i: usize) -> Option<&(f32, Vec<(f32, f32)>)> { + while self.rows.len() <= i && !self.done { + match self.area.next_row(self.next, self.m.ascent, self.m.descent, self.min_width) { + Some((b, spans)) if self.rows.len() < MAX_AREA_ROWS => { + self.next = b + self.m.leading; + self.rows.push((b, spans)); + } + _ => self.done = true, + } + } + self.rows.get(i) + } + + /// The first span, from span `span` of row `row` on, that leaves at least `width` once + /// `inset` (the paragraph's indents) is taken off: its row and span. + fn first_fit(&mut self, row: usize, span: usize, width: f32, inset: f32) -> Option<(usize, usize)> { + let (mut r, mut first) = (row, span); + while let Some((_, spans)) = self.get(r) { + if let Some(j) = spans.iter().skip(first).position(|&(x0, x1)| x1 - x0 - inset + 0.5 >= width) { + return Some((r, first + j)); + } + r += 1; + first = 0; + } + None + } +} + +/// A line wider than the span it fell in, that a later span can hold: the line's index in the +/// paragraph, where its text starts, and its width. +#[derive(Clone, Copy, Debug)] +struct Need { + line: usize, + start: usize, + width: f32, +} + +/// Breaks one paragraph of area type a line at a time, each line as wide as the next span the +/// shape leaves (rows from `from` down, spans left to right). Returns each line's slot and +/// whether text was left over because the shape ran out (hidden, like box overflow). +/// +/// A span too narrow for the word that would start it (the narrow top of an ellipse) is left +/// empty and the word goes to the first later span that holds it, as Photoshop does. A word no +/// later span holds but another span of the shape would means the shape is full: it and the rest are +/// hidden. Only a word no span holds overflows its span. Parley doesn't report a line's +/// trailing whitespace while breaking, so overflowing lines are found after a pass and the +/// paragraph broken again; `budget` (shared by the paragraph's calls) bounds the passes. +/// +/// Horizontal scale applies after shaping, so each line breaks at its span width divided by +/// `scale`; widths compared with spans are the scaled ones. +#[allow(clippy::too_many_arguments)] +fn break_area( + layout: &mut Layout, + area: &crate::area::AreaShape, + from: f32, + m: LineMetrics, + indents: (f32, f32), + min_width: f32, + scale: f32, + budget: &std::cell::Cell, +) -> (Vec, bool) { + let inset = indents.0 + indents.1; + let mut rows = AreaRows { area, m, min_width, next: from, rows: Vec::new(), done: false }; + // Every row of the shape from its top, whatever paragraph came before (found lazily). + let mut shape_rows = AreaRows { area, m, min_width, next: area.bounds[1], rows: Vec::new(), done: false }; + let mut needs: Vec = Vec::new(); + loop { + let (slots, overflow) = break_area_pass(layout, &mut rows, inset, scale, &needs); + budget.set(budget.get().saturating_sub(1)); + // Each line's text start and scaled width without its trailing whitespace. + let lines: Vec<(usize, f32)> = layout + .lines() + .map(|l| { + let lm = l.metrics(); + (l.text_range().start, (lm.advance - lm.trailing_whitespace) * scale) + }) + .collect(); + // A line still wider than its span that some span of the shape would hold: the shape ran + // out of room for it (the narrow bottom of a full bubble). It and the rest are hidden, as + // overflow; only a word no span holds overflows its span. + let mut ran_out = |slots: Vec, overflow: bool| match lines + .iter() + .zip(&slots) + .position(|(&(_, width), s)| width > s.width + 0.5 && shape_rows.first_fit(0, 0, width, inset).is_some()) + { + Some(li) => (slots.into_iter().take(li).collect(), true), + None => (slots, overflow), + }; + if budget.get() == 0 { + return ran_out(slots, overflow); + } + // A line that now starts elsewhere (an earlier line changed) no longer needs its span. + let kept = needs.len(); + needs.retain(|n| lines.get(n.line).is_some_and(|l| l.0 == n.start)); + if needs.len() != kept { + continue; + } + let mut new = None; + for (li, (&(start, width), s)) in lines.iter().zip(&slots).enumerate() { + if width > s.width + 0.5 && !needs.iter().any(|n| n.line == li) && rows.first_fit(s.row, s.span + 1, width, inset).is_some() { + new = Some(Need { line: li, start, width }); + break; + } + } + if let Some(n) = new { + needs.push(n); + continue; + } + return ran_out(slots, overflow); + } +} + +/// One pass of [`break_area`]: each line in the next span, skipping spans too narrow for a +/// line in `needs` when a later span holds it. +fn break_area_pass(layout: &mut Layout, rows: &mut AreaRows<'_>, inset: f32, scale: f32, needs: &[Need]) -> (Vec, bool) { + #[cfg(test)] + AREA_PASSES.with(|c| c.set(c.get() + 1)); + let mut slots: Vec = Vec::new(); + let mut br = layout.break_lines(); + // Per-line widths below are never wider than this, as parley requires. + br.state_mut().set_layout_max_advance(f32::INFINITY); + let (mut row, mut span) = (0, 0); + let mut overflow = false; + while !br.is_done() { + let Some((baseline, spans)) = rows.get(row) else { + overflow = true; + break; + }; + let baseline = *baseline; + let Some(&(x0, x1)) = spans.get(span) else { + row += 1; + span = 0; + continue; + }; + let width = (x1 - x0 - inset).max(1.0); + // A line known to be too wide here moves on to the first span that holds it. + if let Some(w) = needs.iter().find(|n| n.line == slots.len()).map(|n| n.width) + && w > width + 0.5 + && let Some((r, j)) = rows.first_fit(row, span + 1, w, inset) + { + (row, span) = (r, j); + continue; + } + br.state_mut().set_line_max_advance(width / scale); + if br.break_next().is_none() { + break; + } + // Parley aligns each line within the width it was broken at. + br.set_prior_line_width(width / scale); + slots.push(Slot { baseline, x: x0, width, row, span }); + span += 1; + } + br.finish(); + (slots, overflow) +} + +/// One laid-out line's ascent, descent and leading, as the line loop stacks it. `fallback` is +/// the paragraph's first style, used for a line with no glyph runs. +fn line_metrics(line: &parley::Line<'_, RunBrush>, styles: &[CharStyle], ps: &ParagraphStyle, k: f32, fallback: &CharStyle, vertical: bool) -> LineMetrics { + let first_px = fallback.size_pt * k; + let m = line.metrics(); + // Largest leading among the glyph runs, and half the largest em (a vertical column's half width). + let mut leading = 0.0f32; + let mut half = 0.0f32; + for item in line.items() { + if let PositionedLayoutItem::GlyphRun(gr) = item { + let px = gr.run().font_size(); + half = half.max(px * 0.5); + let lp = styles.get(gr.style().brush.0 as usize).and_then(|st| st.leading_pt); + leading = leading.max(lp.map_or(ps.auto_leading * px, |l| l * k)); + } + } + if leading == 0.0 { + leading = fallback.leading_pt.map_or(ps.auto_leading * first_px, |l| l * k); + } + let (ascent, descent) = if vertical { + let h = if half > 0.0 { half } else { first_px * 0.5 }; + (h, h) + } else if m.ascent > 0.0 || m.descent > 0.0 { + (m.ascent, m.descent) + } else { + (first_px * 0.8, first_px * 0.2) + }; + LineMetrics { ascent, descent, leading } +} + +/// Largest ascent, descent and leading over the lines of a broken paragraph, by +/// [`line_metrics`], so an area paragraph's rows are planned with the metrics it is stacked with. +fn area_metrics(layout: &Layout, styles: &[CharStyle], ps: &ParagraphStyle, k: f32, fallback: &CharStyle, vertical: bool) -> LineMetrics { + layout.lines().map(|line| line_metrics(&line, styles, ps, k, fallback, vertical)).fold(LineMetrics { ascent: 0.0, descent: 0.0, leading: 0.0 }, |a, b| { + LineMetrics { ascent: a.ascent.max(b.ascent), descent: a.descent.max(b.descent), leading: a.leading.max(b.leading) } + }) +} + /// A tab as line breaking saw it: its shaped advance (before horizontal scale), the horizontal /// scale, and the width that takes it to its tab stop on its line. struct TabWidth { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index af4de19f52..a19588439f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -4,7 +4,8 @@ //! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list, //! user font data, TrueType collections, PostScript-name lookup. Builds made with the optional //! craft-fonts input -//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its Japanese fonts as fallbacks. +//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its fonts of every script (a single-face +//! file under its manifest family name); the ones the fallback lists name are fallbacks. //! * [`layout`]: shaping and line layout with [parley] (HarfRust shaping, bidi, line breaking), //! point and paragraph (box) text, per-run styles, Photoshop leading/indent/spacing rules. //! * [`render`]: anti-aliased rasterization (exact-area accumulation, f32 coverage) into a @@ -16,6 +17,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod area; pub mod cjk; pub mod craft_fonts; pub mod engine_data; @@ -24,6 +26,7 @@ pub mod glyphs; pub mod layout; pub mod optical; pub mod psd; +mod psd_path; pub mod psd_styles; pub mod raster; pub mod render; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index a75ec05915..4f3f54c367 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -223,12 +223,59 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { layer.sync_summary(); } -/// The frame (`Txt2 /0 /8 /0` entry) of text object `index`: its rendered frame's `/10` names it. -fn txt2_frame(txt2: &E, index: usize) -> Option<&E> { +/// The frame (`Txt2 /0 /8 /0` entry) of text object `index`: its rendered frame's `/10` names it +/// (with `named`, else the frame its view names; see [`object_frame`]). +fn txt2_frame(txt2: &E, index: usize, named: bool) -> Option<&E> { let object = txt2.path(&["1", "1"]).and_then(E::as_array)?.get(index)?; - let frame = - object.path(&["1", "2"]).and_then(E::as_array)?.first()?.get("6").and_then(E::as_array)?.iter().find_map(|f| f.get("10").and_then(E::as_i64))?; - txt2.path(&["0", "8", "0"]).and_then(E::as_array)?.get(usize::try_from(frame).ok()?)?.get("0") + txt2.path(&["0", "8", "0"]).and_then(E::as_array)?.get(object_frame(object, named)?)?.get("0") +} + +/// The number of the frame the text object `object` is drawn in: its rendered frame's `/10`, else +/// (with `named`) the frame its view names (`/1 /0 [ << /0 n >> ]`). Photoshop leaves the rendered +/// strikes out for type it did not lay out (`text/path-wave-open.psd`, whose EngineData text is +/// blank). +fn object_frame(object: &E, named: bool) -> Option { + let rendered = object + .path(&["1", "2"]) + .and_then(E::as_array) + .and_then(|s| s.first()) + .and_then(|s| s.get("6")) + .and_then(E::as_array) + .and_then(|parts| parts.iter().find_map(|f| f.get("10").and_then(E::as_i64))); + let view = || named.then(|| object.path(&["1", "0"]).and_then(E::as_array).and_then(|f| f.first()).and_then(|f| f.get("0")).and_then(E::as_i64)).flatten(); + usize::try_from(rendered.or_else(view)?).ok() +} + +/// The `TySh` of `tysh` and the `Txt2` frame of its text object (`named`: see [`object_frame`]), +/// when that object still holds `layer`'s text (an edit elsewhere leaves `Txt2` stale). +fn layer_frame<'a>(layer: &TextLayer, tysh: &[u8], txt2: &'a E, named: bool) -> Option<(TySh, &'a E)> { + let t = parse_tysh(tysh)?; + let index = match t.text.get("TextIndex") { + Some(D::Integer(i)) => usize::try_from(*i).ok()?, + _ => return None, + }; + let text = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.path(&["0", "0"])).and_then(E::as_str)?; + if text.strip_suffix('\r').unwrap_or(text).replace('\r', "\n") != layer.text { + return None; + } + let frame = txt2_frame(txt2, index, named)?; + Some((t, frame)) +} + +/// Puts `layer` on its path when its `Txt2` frame is type on a path (#7962): Photoshop keeps the +/// path only there, and EngineData describes the layer as a text box the size of the path's +/// bounds. The path is read into the `TySh` text space, the type starting where Photoshop starts +/// it (see `psd_path`). `Ok(true)` when the layer is now path text, `Ok(false)` when its frame is +/// no path (or `Txt2` holds another text for it), `Err` with the reason when the path can't be +/// read: the layer then stays the box or point text EngineData describes. +pub fn apply_txt2_path(layer: &mut TextLayer, tysh: &[u8], txt2: &E) -> Result { + let Some((_, frame)) = layer_frame(layer, tysh, txt2, true) else { return Ok(false) }; + if crate::psd_path::frame_kind(frame) != crate::psd_path::PATH_FRAME { + return Ok(false); + } + let path = crate::psd_path::read(frame)?; + layer.shape = TextShape::Path { path, start: 0.0, flip: false }; + Ok(true) } /// Places `layer` where Photoshop draws it when the text object's frame in `Txt2` is moved @@ -240,18 +287,11 @@ fn txt2_frame(txt2: &E, index: usize) -> Option<&E> { /// alignment anchor. Only when the layer's `TextIndex` entry still holds the layer's text, as /// for [`apply_txt2`], and the frame agrees with the `TySh` `bounds`. pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { - let Some(t) = parse_tysh(tysh) else { return }; - let Some(index) = (match t.text.get("TextIndex") { - Some(D::Integer(i)) => usize::try_from(*i).ok(), - _ => None, - }) else { - return; - }; - let text = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.path(&["0", "0"])).and_then(E::as_str); - if text.is_none_or(|t| t.strip_suffix('\r').unwrap_or(t).replace('\r', "\n") != layer.text) { + // Type on a path: [`apply_txt2_path`] already read the path through the frame's matrix. + if matches!(layer.shape, TextShape::Path { .. }) { return; } - let Some(frame) = txt2_frame(txt2, index) else { return }; + let Some((t, frame)) = layer_frame(layer, tysh, txt2, false) else { return }; let m = arr_f(frame.path(&["2", "2"])); let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY }; let at = match layer.shape { @@ -259,7 +299,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let p = arr_f(frame.get("0")); if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY } } - TextShape::Box { .. } => Affine::IDENTITY, + TextShape::Box { .. } | TextShape::Area { .. } => Affine::IDENTITY, }; let to_text = matrix.mul(&at); if to_text == Affine::IDENTITY || to_text.determinant().abs() <= 1e-9 { @@ -272,7 +312,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { // again would move it twice. let origin = match layer.shape { TextShape::Point | TextShape::Path { .. } => Point::new(0.0, 0.0), - TextShape::Box { .. } => { + TextShape::Box { .. } | TextShape::Area { .. } => { let b = engine_data(&t.text) .map(|e| { arr_f( @@ -390,12 +430,46 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { // writing the TySh all agree on the visible position (#1469). if let Some([x, y]) = legacy_point_origin(&t) { let [a, b, c, d, e, f] = layer.transform.m; - layer.transform.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f]; + let corrected = [a, b, c, d, a * x + c * y + e, b * x + d * y + f]; + if corrected.iter().all(|v| v.is_finite()) { + layer.transform.m = corrected; + } } layer.sync_summary(); Some(layer) } +/// Refines a legacy point-text estimate after imported fonts and styles have been resolved. +/// Uses the rendering engine's font context; an accepted `Txt2` frame stays authoritative. +pub fn refine_legacy_point_origin(layer: &mut TextLayer, dpi: f32, engine: &mut crate::TextEngine, txt2: Option<&E>) { + let Some(raw) = layer.psd_raw.clone() else { return }; + let Some(t) = parse_tysh(&raw) else { return }; + let Some([x, y]) = legacy_point_origin(&t) else { return }; + if !matches!(layer.shape, TextShape::Point) { + return; + } + let guessed = t.transform.mul(&Affine::translate(x, y)); + if layer.transform != guessed { + return; + } + // `boundingBox` is the tight ink box (Photoshop writes the layout box as `bounds`), so match + // it with the layout's ink, not its logical box: text whose ink starts below the ascent + // line (`...`, `_`, lowercase) would otherwise land too low. + let Some(ink) = crate::render::ink_bounds(&engine.layout(layer, dpi)) else { return }; + if !ink.iter().all(|v| v.is_finite()) { + return; + } + let corrected = t.transform.mul(&Affine::translate(x - ink[0], y - ink[1])); + if corrected.m.iter().all(|v| v.is_finite()) { + layer.transform = corrected; + // A valid frame can coincide with the initial estimate; numeric equality cannot tell + // whether it was used. Reapply it after the fallback so it always wins. + if let Some(txt2) = txt2 { + apply_txt2_frame(layer, &raw, txt2); + } + } +} + /// The local origin to fold into a legacy point-text transform, if present. /// /// Photoshop normally keeps point text close to its PointBase. The affected legacy files instead @@ -426,7 +500,11 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> { // A local bounding-box origin in the thousands and a transform origin beyond a normal canvas // are the signature of this old writer. Requiring that the bounding-box origin is closer to // the document prevents applying the correction again after export/import. - (bounds[0].abs().max(bounds[1].abs()) > 1024.0 && origin_distance > 4096.0 && shifted_distance < origin_distance).then_some([bounds[0], bounds[1]]) + if !(bounds[0].abs().max(bounds[1].abs()) > 1024.0 && origin_distance > 4096.0 && shifted_distance < origin_distance) { + return None; + } + + Some([bounds[0], bounds[1]]) } fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> { @@ -435,15 +513,22 @@ fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> { (bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1]).then_some(bounds) } +const MAX_ENGINE_RUNS: usize = 65_536; + pub(crate) fn arr_f(v: Option<&E>) -> Vec { - v.and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default() + v.and_then(E::as_array).map(|a| a.iter().take(MAX_ENGINE_RUNS).filter_map(E::as_f64).collect()).unwrap_or_default() } -/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths. +/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths, stopping after the first run +/// once `text` is exhausted so trailing runs past the text aren't processed. pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec { - let mut out = Vec::with_capacity(lens.len()); + let mut out = Vec::with_capacity(lens.len().min(text.len().saturating_add(1))); let mut chars = text.chars().peekable(); - for &l in lens { + let mut exhausted = false; + for &l in lens.iter().take(MAX_ENGINE_RUNS) { + if exhausted { + break; + } let mut units = l.max(0.0) as usize; let mut bytes = 0; while units > 0 { @@ -452,9 +537,15 @@ pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec { units = units.saturating_sub(c.len_utf16()); bytes += c.len_utf8(); } - None => break, + None => { + exhausted = true; + break; + } } } + if chars.peek().is_none() { + exhausted = true; + } out.push(bytes); } out @@ -495,17 +586,22 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { // Paragraph runs first: a character run's defaults depend on its paragraph. let prun = e.path(&["EngineDict", "ParagraphRun"]); - let psheets: Vec<&E> = - prun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).collect()).unwrap_or_default(); let plens16 = arr_f(prun.and_then(|r| r.get("RunLengthArray"))); let plens = utf16_to_byte_lengths(&ed_text, &plens16); + let max_pruns = plens.len().max(1); + let psheets: Vec<&E> = prun + .and_then(|r| r.get("RunArray")) + .and_then(E::as_array) + .map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).take(max_pruns).collect()) + .unwrap_or_default(); + let plens16 = &plens16[..plens16.len().min(psheets.len())]; let empty = E::Dict(Vec::new()); - // Per paragraph run: its merged base properties and its default character style. - let para_bases: Vec<(E, Option<&E>)> = psheets + // Per paragraph run: its borrowed base property layers and its default character style. + let para_bases: Vec<([Option<&E>; 2], Option<&E>)> = psheets .iter() .map(|sheet| { let style = named(para_set, sheet).and_then(|i| para_set.get(i)); - let base = merged(&[style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]); + let base = [style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]; let char_default = [Some(*sheet), style] .into_iter() .flatten() @@ -517,15 +613,19 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { .iter() .zip(¶_bases) .zip(plens) - .map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(Some(base), sheet.get("Properties").unwrap_or(&empty), k) }) + .map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(base, sheet.get("Properties").unwrap_or(&empty), k) }) .collect(); // Character runs. let srun = e.path(&["EngineDict", "StyleRun"]); - let ssheets: Vec<&E> = - srun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).collect()).unwrap_or_default(); let slens16 = arr_f(srun.and_then(|r| r.get("RunLengthArray"))); let slens = utf16_to_byte_lengths(&ed_text, &slens16); + let max_sruns = slens.len().max(1); + let ssheets: Vec<&E> = srun + .and_then(|r| r.get("RunArray")) + .and_then(E::as_array) + .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). let para_of = |at: f64| -> Option { let mut end = 0.0; @@ -538,14 +638,14 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { plens16.len().checked_sub(1) }; let mut at16 = 0.0; - let bases: Vec = ssheets + let bases: Vec<[Option<&E>; 3]> = ssheets .iter() .enumerate() .map(|(i, sheet)| { let own = named(style_set, sheet).and_then(|i| style_set.get(i)); let para_default = para_of(at16).and_then(|p| para_bases.get(p)).and_then(|(_, c)| *c); at16 += slens16.get(i).copied().unwrap_or(0.0); - merged(&[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData")))) + [own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData"))) }) .collect(); let sdata: Vec<&E> = ssheets.iter().map(|s| s.get("StyleSheetData").unwrap_or(&empty)).collect(); @@ -554,12 +654,12 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { .zip(&bases) .zip(&slens) .map(|((d, base), len)| { - let auto = lookup(d, Some(base), "AutoKerning").and_then(E::as_bool) != Some(false); - let kern = lookup(d, Some(base), "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32; + let auto = lookup(d, base, "AutoKerning").and_then(E::as_bool) != Some(false); + let kern = lookup(d, base, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32; (*len, auto, kern) }) .collect(); - layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(Some(base), d, &fonts, k) }).collect(); + layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(base, d, &fonts, k) }).collect(); layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming); // Shape. @@ -574,26 +674,11 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { } } -/// The dictionaries in `levels` as one: a key comes from the first level that has it. -fn merged(levels: &[Option<&E>]) -> E { - let mut out: Vec<(String, E)> = Vec::new(); - for level in levels.iter().flatten() { - if let E::Dict(kv) = level { - for (k, v) in kv { - if !out.iter().any(|(o, _)| o == k) { - out.push((k.clone(), v.clone())); - } - } - } - } - E::Dict(out) +fn lookup<'a>(run: &'a E, base: &[Option<&'a E>], key: &str) -> Option<&'a E> { + run.get(key).or_else(|| base.iter().flatten().find_map(|b| b.get(key))) } -fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> { - run.get(key).or_else(|| base.and_then(|b| b.get(key))) -} - -pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> CharStyle { +pub(crate) fn char_style(base: &[Option<&E>], d: &E, fonts: &[String], k: f32) -> CharStyle { let g = |key: &str| lookup(d, base, key); let num = |key: &str| g(key).and_then(E::as_f64); // An f32 that a huge finite f64 overflowed to infinity is dropped too. @@ -658,7 +743,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C s } -pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { +pub(crate) fn para_style(base: &[Option<&E>], d: &E, k: f32) -> ParagraphStyle { let num = |key: &str| lookup(d, base, key).and_then(E::as_f64); let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); ParagraphStyle { @@ -1113,6 +1198,14 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi let prev = previous.and_then(parse_txt2); let prev_objects: &[E] = prev.as_ref().and_then(|p| p.path(&["1", "1"]).and_then(E::as_array)).unwrap_or(&[]); let mut slots: Vec = prev_objects.to_vec(); + // The older Photoshop's objects (#6971): named keys at `/DocumentObjects /TextObjects` + // instead of the numbered `/1 /1`, one entry per layer by `TextIndex` position (the same + // numbering the slots take), its text under `Model /Text` and its run lengths under `/Length`. + // Photoshop re-renders a type layer from this copy when the file has one, so an edit that + // left the old text here showed the old text back on reopen. The entries are only ever edited + // 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; 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(); // Looked up once per save, not per layer: a crafted block with many objects must not cost @@ -1120,6 +1213,20 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi let all_stubs = prev_objects.iter().all(is_stub); // A template is copied once per new layer, so a large one is skipped (the stub is written). let template = prev_objects.iter().rev().find(|o| names_one_frame(o) && fits(o, &mut { TEMPLATE_NODES }) && edited_object(o, "\r").is_some()); + // The frame each written object is drawn in, with its layer's shape (type on a path lives + // only in the frame, #7962). + let mut frame_shapes: Vec<(usize, &TextShape)> = Vec::new(); + // A new or rebuilt object must not be drawn in a frame another object is drawn in: the two + // layers would share one shape. The frames the file's objects name are taken; a free one is + // found past the frame list and the slots (`pad_frames` adds it). + let frame_count = prev.as_ref().and_then(|p| p.path(&["0", "8", "0"])).and_then(E::as_array).map(<[E]>::len); + let mut taken: std::collections::HashSet = prev_objects.iter().filter_map(|o| object_frame(o, true)).collect(); + let slot_count = + objects.iter().filter_map(|(i, _)| usize::try_from(*i).ok().filter(|_| (0..=MAX_TEXT_INDEX).contains(i))).map(|i| i + 1).max().unwrap_or(0); + let mut next_free = frame_count.unwrap_or(0).max(prev_objects.len()).max(slot_count); + // The frame list grows only for the frames handed out here, never for a frame number an + // object the file kept names past its list. + let mut frames_needed = 0usize; for (index, layer) in objects { if !(0..=MAX_TEXT_INDEX).contains(index) { continue; @@ -1127,6 +1234,21 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi let text = format!("{}\r", layer.text.replace('\n', "\r")); let modes = txt2_mode_runs(layer); let at = *index as usize; + // The older Photoshop's copy of this object (#6971), refitted like the numbered one + // below. The entry's own text is the check, so a file a previous save left with a stale + // copy beside a current `/1 /1` object is repaired here too. + if let Some(entries) = td.as_mut() { + let fixed = match entries.get(at) { + Some(entry) if entry.path(&["Model", "Text"]).and_then(E::as_str) != Some(text.as_str()) => td_edited_object(entry, &text), + _ => None, + }; + if let Some(fixed) = fixed + && let Some(slot) = entries.get_mut(at) + { + *slot = fixed; + 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. @@ -1140,8 +1262,28 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi // A new object (#7698): a layer the file's block has no object for (a type layer added // here) takes the shape of one of the file's own objects, refitted the same way, with its // view naming its own frame. A bare stub beside Photoshop's objects, its slot without a - // frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. - let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten(); + // frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. A slot whose object + // can't be refitted keeps the frame that object was drawn in. + let fresh = (kept.is_none() && edited.is_none()) + .then(|| { + let template = template?; + 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) => { + while taken.contains(&next_free) { + next_free += 1; + } + if next_free > MAX_TEXT_INDEX as usize { at } else { next_free } + } + None => at, + }; + let object = new_object(template, frame, &text)?; + taken.insert(frame); + frames_needed = frames_needed.max(frame.saturating_add(1)); + Some(object) + }) + .flatten(); let reuse = kept.is_some() || edited.is_some(); let unchanged = kept.is_some(); let mut object = kept.cloned().or(edited).or(fresh).unwrap_or_else(E::dict); @@ -1171,6 +1313,9 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi _ => model.set("6", E::Dict(vec![("0".into(), E::Array(runs))])), } object.set("0", model); + if let Some(frame) = object_frame(&object, true) { + frame_shapes.push((frame, &layer.shape)); + } if slots.len() <= at { slots.resize(at + 1, E::dict()); } @@ -1181,7 +1326,24 @@ 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())])); - pad_frames(&mut outer, slots.len()); + 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 + // more than one object drawn in (which layer's shape it should hold is unknown). + let mut claims: std::collections::HashMap = std::collections::HashMap::new(); + for (at, _) in &frame_shapes { + *claims.entry(*at).or_default() += 1; + } + if let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) { + for (at, shape) in frame_shapes { + if claims.get(&at) != Some(&1) { + continue; + } + if let Some(entry) = frames.get_mut(at) { + crate::psd_path::update_frame(entry, shape); + } + } + } if fonts.len() != fonts_before && let Some(list @ E::Dict(_)) = outer.get_mut("0").and_then(|v| v.get_mut("1")) { @@ -1191,6 +1353,14 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)), _ => outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))])), } + // The older Photoshop's objects, whatever the loop fixed in them (an untouched list is left + // exactly as the file has it, so the byte-for-byte return below still holds). + if td_changed + && let Some(entries) = td + && let Some(objects @ E::Dict(_)) = outer.get_mut("DocumentObjects") + { + objects.set("TextObjects", E::Array(entries)); + } // Nothing changed: the file's own bytes (writing them back would re-encode strings and numbers). if let (Some(bytes), Some(p)) = (previous, &prev) && *p == outer @@ -1208,6 +1378,12 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi /// own objects hold a few hundred (611 at most in the file from #7698). const TEMPLATE_NODES: usize = 4096; +/// Cumulative node and byte budgets across all run dictionaries cloned while refitting one `Txt2` +/// object (`runs_per` and `runs_to_length`), so an edited object with a large preserved run and +/// many output paragraphs falls back to a fresh object instead of amplifying memory. +const MAX_REFIT_RUN_NODES: usize = 16_384; +const MAX_REFIT_RUN_BYTES: usize = 1 << 20; + /// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over). fn fits(v: &E, budget: &mut usize) -> bool { let Some(left) = budget.checked_sub(1) else { return false }; @@ -1219,6 +1395,27 @@ fn fits(v: &E, budget: &mut usize) -> bool { } } +/// Whether `v` fits within the remaining `(nodes, bytes)` budget, deducting its nodes and string +/// bytes as it walks. +fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool { + let Some(left) = nodes.checked_sub(1) else { return false }; + *nodes = left; + match v { + E::Dict(items) => items.iter().all(|(k, x)| { + let Some(rem) = bytes.checked_sub(k.len()) else { return false }; + *bytes = rem; + fits_budget(x, nodes, bytes) + }), + E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)), + E::String(s) | E::Name(s) => { + let Some(rem) = bytes.checked_sub(s.len()) else { return false }; + *bytes = rem; + true + } + _ => true, + } +} + /// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as /// Photoshop writes it. fn names_one_frame(object: &E) -> bool { @@ -1246,7 +1443,8 @@ fn new_object(template: &E, at: usize, text: &str) -> Option { } } if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) { - let fitted = runs_per(runs, ¶graphs)?; + let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES); + let fitted = runs_per(runs, ¶graphs, "1", &mut nodes, &mut bytes)?; view.get_mut("1")?.set("0", E::Array(fitted)); } Some(object) @@ -1269,10 +1467,7 @@ fn pad_frames(outer: &mut E, count: usize) { let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) else { return }; let count = count.min(MAX_TEXT_INDEX as usize + 1); if frames.len() < count { - let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]); - let data = E::Dict(vec![("6".into(), E::Array(vec![E::Real(-1.0), E::Real(-1.0)])), ("11".into(), kind)]); - let frame = E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]); - frames.resize(count, frame); + frames.resize(count, crate::psd_path::point_frame()); } } @@ -1291,6 +1486,7 @@ fn edited_object(object: &E, text: &str) -> Option { let total = utf16_len(text) as usize; let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect(); let E::Dict(entries) = model else { return None }; + let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES); for (key, value) in entries.iter_mut() { if key.as_str() == "0" { *value = E::String(text.to_string()); @@ -1299,36 +1495,98 @@ fn edited_object(object: &E, text: &str) -> Option { let Some(runs) = value.get("0").and_then(E::as_array).filter(|r| r.iter().all(|x| matches!(x, E::Dict(_)) && x.get("1").is_some())) else { continue; }; - let fitted = if key.as_str() == "5" { runs_per(runs, ¶graphs)? } else { runs_to_length(runs, total)? }; + let fitted = if key.as_str() == "5" { + runs_per(runs, ¶graphs, "1", &mut nodes, &mut bytes)? + } else { + runs_to_length(runs, total, "1", &mut nodes, &mut bytes)? + }; value.set("0", E::Array(fitted)); } Some(object) } -/// `runs` (`<< … /1 length >>`) remade with the lengths `lens`: run i keeps the content of the -/// file's run i, else of its last one. `None` for an empty `runs`. -fn runs_per(runs: &[E], lens: &[usize]) -> Option> { +/// The older Photoshop's `Txt2` text object `object` (`<< /Model << /Text … /ParagraphRun … >> +/// /View … >>`) for the changed engine text `text` (`\r` breaks, trailing `\r`): the model's text +/// replaced and every run array of the model refitted the way [`edited_object`] refits the +/// numbered objects' — the paragraph runs (`/ParagraphRun`) one per paragraph of the new text, the +/// others (`/StyleRun`, `/KernRun`, …) cut or extended to the new total. The model's other keys, +/// the view and the rest are kept: these blocks (the objects at `/DocumentObjects /TextObjects` +/// that older Photoshop versions write and re-render from, #6971) name their keys and number +/// their runs with `/Length` in UTF-16 units instead of `/1`. `None` when the object or one of +/// its run arrays isn't shaped as Photoshop writes it: the caller then leaves the entry as the +/// file has it, rather than write text its runs don't cover (#6968). +fn td_edited_object(object: &E, text: &str) -> Option { + let mut object = object.clone(); + let model = object.get_mut("Model").filter(|m| matches!(m, E::Dict(_)))?; + 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(_))) { + return None; + } + let total = utf16_len(text) as usize; + let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect(); + let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES); + for (key, value) in entries.iter_mut() { + if key.as_str() == "Text" { + *value = E::String(text.to_string()); + continue; + } + if !matches!(value, E::Dict(_)) { + continue; + } + // A model dict without `RunArray` isn't a run array (the view's keys live elsewhere), so + // it is skipped; a named `RunArray` is unambiguous — malformed, the whole entry is left to + // the file rather than half-updated. + let Some(runs) = value.get("RunArray") else { continue }; + let fitted = match runs { + E::Array(runs) if runs.iter().all(|x| matches!(x, E::Dict(_)) && x.get("Length").is_some()) => { + if key.as_str() == "ParagraphRun" { + runs_per(runs, ¶graphs, "Length", &mut nodes, &mut bytes)? + } else { + runs_to_length(runs, total, "Length", &mut nodes, &mut bytes)? + } + } + _ => return None, + }; + value.set("RunArray", E::Array(fitted)); + } + Some(object) +} + +/// `runs` (`<< … /1 length >>`, the length key named `len_key`) remade with the lengths `lens`: +/// run i keeps the content of the file's run i, else of its last one. `None` for an empty `runs` +/// or when the cumulative `nodes` / `bytes` budget is exceeded. +fn runs_per(runs: &[E], lens: &[usize], len_key: &str, nodes: &mut usize, bytes: &mut usize) -> Option> { let last = runs.last()?; lens.iter() .enumerate() .map(|(i, &len)| { - let mut r = runs.get(i).unwrap_or(last).clone(); - r.set("1", E::Int(i64::try_from(len).ok()?)); + let src = runs.get(i).unwrap_or(last); + if !fits_budget(src, nodes, bytes) { + return None; + } + let mut r = src.clone(); + r.set(len_key, E::Int(i64::try_from(len).ok()?)); Some(r) }) .collect() } -/// `runs` (`<< … /1 length >>`) covering `total` UTF-16 units: the leading runs as they were, cut -/// where `total` ends, the last kept run extended when `total` is longer. Empty runs are dropped. -/// `None` when `runs` is empty or a length isn't a count. -fn runs_to_length(runs: &[E], total: usize) -> Option> { +/// `runs` (`<< … /1 length >>`, the length key named `len_key`) covering `total` UTF-16 units: +/// the leading runs as they were, cut where `total` ends, the last kept run extended when `total` +/// is longer. Empty runs are dropped. `None` when `runs` is empty, a length isn't a count, or the +/// cumulative `nodes` / `bytes` budget is exceeded. +fn runs_to_length(runs: &[E], total: usize, len_key: &str, nodes: &mut usize, bytes: &mut usize) -> Option> { let mut out: Vec<(E, usize)> = Vec::new(); let mut left = total; for r in runs { - let len = r.get("1").and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?; + let len = r.get(len_key).and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?; let take = len.min(left); if take > 0 { + if !fits_budget(r, nodes, bytes) { + return None; + } out.push((r.clone(), take)); left -= take; } @@ -1336,12 +1594,18 @@ fn runs_to_length(runs: &[E], total: usize) -> Option> { if left > 0 { match out.last_mut() { Some(last) => last.1 = last.1.checked_add(left)?, - None => out.push((runs.last()?.clone(), left)), + None => { + let last = runs.last()?; + if !fits_budget(last, nodes, bytes) { + return None; + } + out.push((last.clone(), left)); + } } } out.into_iter() .map(|(mut r, len)| { - r.set("1", E::Int(i64::try_from(len).ok()?)); + r.set(len_key, E::Int(i64::try_from(len).ok()?)); Some(r) }) .collect() @@ -1520,10 +1784,18 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E if dict.get("UseFractionalGlyphWidths").is_none() { dict.set("UseFractionalGlyphWidths", E::Bool(true)); } - let (shape_type, box_bounds) = match layer.shape { - // PSD path type is not written yet: the layer saves as point text. + let (shape_type, box_bounds) = match &layer.shape { + // Type on a path: the path goes into the layer's `Txt2` frame ([`build_txt2`], #7962), + // which only a file that already has a `Txt2` object for the layer gets; EngineData + // keeps it as point text, one line for readers (and files) without that frame. TextShape::Point | TextShape::Path { .. } => (0, None), - TextShape::Box { x, y, width, height } => (1, Some([x, y, x + width, y + height])), + TextShape::Box { x, y, width, height } => (1, Some([*x, *y, x + width, y + height])), + // PSD area type is not written yet: the layer saves as paragraph text over the path's + // bounds, so Photoshop opens it with the same text wrapping in a rectangle. + TextShape::Area { path } => match crate::area::path_bounds(path) { + Some((x0, y0, x1, y1)) => (1, Some([x0 as f32, y0 as f32, x1 as f32, y1 as f32])), + None => (0, None), + }, }; let mut photoshop = E::Dict(vec![("ShapeType".into(), E::Int(shape_type))]); match box_bounds { @@ -1636,9 +1908,14 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec }, ), ); - let [l, t, r, b] = match (&layer.shape, ink) { - (&TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], - (_, Some(i)) => i, + let area = match &layer.shape { + TextShape::Area { path } => crate::area::path_bounds(path).map(|(x0, y0, x1, y1)| [x0 as f32, y0 as f32, x1 as f32, y1 as f32]), + _ => None, + }; + let [l, t, r, b] = match (&layer.shape, area, ink) { + (&TextShape::Box { x, y, width, height }, _, _) => [x, y, x + width, y + height], + (_, Some(a), _) => a, + (_, _, Some(i)) => i, _ => [0.0; 4], }; let rect = |cls: &str| { @@ -1671,6 +1948,26 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } +#[cfg(test)] +mod run_length_safety_tests { + use super::*; + + #[test] + fn utf16_lengths_stop_after_text_is_consumed() { + assert_eq!(utf16_to_byte_lengths("é😀", &[1.0, 2.0, 1.0, 1.0e300]), [2, 4]); + assert_eq!(utf16_to_byte_lengths("", &[1.0, 1.0e300]), [0]); + assert_eq!(utf16_to_byte_lengths("abc", &[0.0, 1.0, 0.0, 1.0, 1.0, 1.0e300]), [0, 1, 0, 1, 1]); + let zero_runs = vec![0.0; MAX_ENGINE_RUNS + 1]; + assert_eq!(utf16_to_byte_lengths("a", &zero_runs).len(), MAX_ENGINE_RUNS); + } + + #[test] + fn engine_run_length_array_conversion_is_bounded() { + let values = E::Array(vec![E::Real(0.0); MAX_ENGINE_RUNS + 1]); + assert_eq!(arr_f(Some(&values)).len(), MAX_ENGINE_RUNS); + } +} + #[cfg(test)] mod transform_safety_tests { use super::*; @@ -1698,17 +1995,17 @@ mod nonfinite_tests { #[test] fn non_finite_style_numbers_fall_back_to_defaults() { let d = parse(b"<< /FontSize 1e999 /Tracking nan /HorizontalScale inf /Kerning -inf /FillColor << /Values [1.0 nan 0.0 0.0] >> >>").unwrap(); - let s = char_style(None, &d, &[], 1.0); + let s = char_style(&[], &d, &[], 1.0); let dflt = CharStyle::default(); assert_eq!(s.size_pt, dflt.size_pt); assert_eq!((s.tracking, s.horizontal_scale, s.kern), (0.0, 1.0, 0.0)); assert_eq!(s.color, Color::BLACK); // A finite f64 too big for f32 is dropped as well. let d = parse(b"<< /FontSize 1e300 /StartIndent inf >> ").unwrap(); - assert_eq!(char_style(None, &d, &[], 1.0).size_pt, dflt.size_pt); - assert_eq!(para_style(None, &d, 1.0).start_indent_pt, 0.0); + assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, dflt.size_pt); + assert_eq!(para_style(&[], &d, 1.0).start_indent_pt, 0.0); let d = parse(b"<< /FontSize 24.0 >>").unwrap(); - assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); + assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, 24.0); } /// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back. @@ -1716,14 +2013,35 @@ mod nonfinite_tests { fn font_baseline_round_trips() { use photocraft_doc::text::FontBaseline; let d = parse(b"<< /FontBaseline 2 >>").unwrap(); - assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Subscript); + assert_eq!(char_style(&[], &d, &[], 1.0).baseline, FontBaseline::Subscript); let d = parse(b"<< /FontBaseline 1 >>").unwrap(); - assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Superscript); + assert_eq!(char_style(&[], &d, &[], 1.0).baseline, FontBaseline::Superscript); let s = CharStyle { baseline: FontBaseline::Subscript, ..Default::default() }; - let back = char_style(None, &style_sheet_data(&s, 0, 1.0), &[], 1.0); + let back = char_style(&[], &style_sheet_data(&s, 0, 1.0), &[], 1.0); assert_eq!(back.baseline, FontBaseline::Subscript); } + #[test] + fn area_path_that_is_not_finite_saves_as_point_text() { + let path = photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polygon(&[(0.0, 0.0), (f64::NAN, 0.0), (10.0, f64::INFINITY)])]); + let layer = TextLayer { + text: "x".into(), + runs: vec![TextRun { len: 1, style: CharStyle { size_pt: 12.0, ..Default::default() } }], + shape: TextShape::Area { path }, + ..Default::default() + }; + let out = String::from_utf8_lossy(&crate::engine_data::write(&build_engine_data(&layer, None, 72.0))).into_owned(); + assert!(out.contains("/PointBase") && !out.contains("/BoxBounds"), "{out}"); + assert!(!out.to_ascii_lowercase().contains("nan") && !out.to_ascii_lowercase().contains("inf"), "{out}"); + let t = parse_tysh(&build_tysh(&layer, 72.0, None)).unwrap(); + for key in ["bounds", "boundingBox"] { + let Some(D::Descriptor(d)) = t.text.get(key) else { panic!("no {key}") }; + for side in ["Left", "Top ", "Rght", "Btom"] { + assert!(matches!(d.get(side), Some(D::UnitFloat { value, .. }) if value.is_finite()), "{key} {side}: {:?}", d.get(side)); + } + } + } + #[test] fn warp_values_beyond_f32_clamp_to_the_warp_range() { let warp = Descriptor::new("warp") diff --git a/crates/text/src/psd_path.rs b/crates/text/src/psd_path.rs new file mode 100644 index 0000000000..773678de1d --- /dev/null +++ b/crates/text/src/psd_path.rs @@ -0,0 +1,641 @@ +//! Type on a path in a PSD (#7962): the path lives only in the type layer's frame in the +//! document's `Txt2` block (EngineData marks such a layer as box text, `ShapeType 1`, with the +//! path's bounds as its `BoxBounds`). +//! +//! Observed layout (Photoshop 2026, `text/path-circle.psd` and `text/path-wave-open.psd` in +//! photocraft-corpus): a frame `<< /0 << /1 << /0 [ … ] >> /2 << /0 2 /2 [ matrix ] … >> >> >>` +//! has the kind `/2 /0` (absent for point text, 1 for a text box, 2 for type on a path) and its +//! outline at `/1 /0` as a flat list of cubic Bézier segments, eight numbers each +//! (`p0 c1 c2 p1`, the next segment starting where the previous one ends, a closed path ending +//! where it started), in the frame's space; `/2 /2` maps the frame's space to the `TySh` text +//! space. On a closed path Photoshop sets the type from the path's last anchor (the circle's +//! glyph 0 sits on its fourth anchor and runs on through the first), on an open path from its +//! first. Neither oracle has a moved start or flipped type, so those are written into the path +//! itself (see [`frame_path`]). + +use photocraft_doc::text::TextShape; +use photocraft_doc::vector::{Knot, PathOp, Subpath}; +use photocraft_geom::{Affine, Point}; + +use crate::engine_data::Value as E; + +/// The `Txt2` frame kind (`/2 /0`) of type on a path. +pub(crate) const PATH_FRAME: i64 = 2; +/// The `Txt2` frame kind (`/2 /0`) of a text box. +const BOX_FRAME: i64 = 1; +/// Most cubic segments read from a frame (a hostile file cannot make this large). +const MAX_SEGMENTS: usize = 4096; +/// How close (px) two points must be to count as the same: Photoshop writes a segment's start as +/// an exact copy of the previous segment's end. +const SAME: f64 = 1e-3; + +/// The kind of the frame dictionary `frame` (the entry's `/0`): 0 point text, 1 box, 2 path. +pub(crate) fn frame_kind(frame: &E) -> i64 { + frame.path(&["2", "0"]).and_then(E::as_i64).unwrap_or(0) +} + +/// Every entry of `v` as a finite number, or `None`. +fn numbers(v: &E) -> Option> { + v.as_array()?.iter().map(E::as_f64).collect() +} + +fn same(a: Point, b: Point) -> bool { + (a.x - b.x).hypot(a.y - b.y) <= SAME +} + +/// Whether the handles `a` (in) and `b` (out) of `anchor` are collinear on opposite sides. +fn smooth(anchor: Point, a: Point, b: Point) -> bool { + let (ax, ay, bx, by) = (a.x - anchor.x, a.y - anchor.y, b.x - anchor.x, b.y - anchor.y); + let (la, lb) = (ax.hypot(ay), bx.hypot(by)); + la > SAME && lb > SAME && (ax * by - ay * bx).abs() <= 1e-3 * la * lb && ax * bx + ay * by < 0.0 +} + +fn knot(in_ctrl: Point, anchor: Point, out_ctrl: Point) -> Knot { + Knot { anchor, in_ctrl, out_ctrl, smooth: smooth(anchor, in_ctrl, out_ctrl) } +} + +/// The type path of a path frame `frame` (the entry's `/0`), in the `TySh` text space, with the +/// type starting at its first knot. `Err` says why it can't be read (the caller keeps the box or +/// point text EngineData describes). +pub(crate) fn read(frame: &E) -> Result { + let raw = frame.path(&["1", "0"]).ok_or("the frame has no path")?; + let v = numbers(raw).ok_or("a path coordinate is not a number")?; + if v.is_empty() || v.len() % 8 != 0 { + return Err(format!("its path has {} coordinates, not whole curve segments", v.len())); + } + if v.len() / 8 > MAX_SEGMENTS { + return Err(format!("its path has more than {MAX_SEGMENTS} segments")); + } + let matrix = match frame.path(&["2", "2"]) { + None => Affine::IDENTITY, + Some(m) => { + let Some([a, b, c, d, e, f]) = numbers(m).and_then(|m| <[f64; 6]>::try_from(m).ok()) else { + return Err("its frame matrix is not six numbers".into()); + }; + let m = Affine { m: [a, b, c, d, e, f] }; + let det = m.determinant(); + if !det.is_finite() || det.abs() <= 1e-12 { + return Err("its frame matrix is degenerate".into()); + } + m + } + }; + let pts: Vec = v.as_chunks::<2>().0.iter().map(|&[x, y]| matrix.apply(Point::new(x, y))).collect(); + if pts.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err("its path is out of range".into()); + } + let segs: Vec<[Point; 4]> = pts.as_chunks::<4>().0.to_vec(); + if segs.windows(2).any(|w| matches!(w, [a, b] if !same(a[3], b[0]))) { + return Err("its path has more than one piece".into()); + } + let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return Err("the frame has no path".into()) }; + let (x0, x1) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x))); + let (y0, y1) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))); + if x1 - x0 <= SAME && y1 - y0 <= SAME { + return Err("its path has no length".into()); + } + let closed = same(last[3], first[0]); + let mut knots: Vec = Vec::with_capacity(segs.len() + 1); + for (i, s) in segs.iter().enumerate() { + let prev = match i.checked_sub(1).and_then(|j| segs.get(j)) { + Some(p) => p[2], + None if closed => last[2], + None => s[0], + }; + knots.push(knot(prev, s[0], s[1])); + } + if closed { + // The type starts on the closing segment: from the last anchor. + knots.rotate_right(1); + } else { + knots.push(knot(last[2], last[3], last[3])); + } + Ok(Subpath { closed, knots, op: PathOp::Combine }) +} + +/// `path` with its knots in the opposite order (the segments run backwards). +fn reversed(path: &Subpath) -> Subpath { + let mut knots: Vec = path.knots.iter().rev().map(|k| Knot { anchor: k.anchor, in_ctrl: k.out_ctrl, out_ctrl: k.in_ctrl, smooth: k.smooth }).collect(); + if path.closed { + // Keep the first knot first: k0, k(n-1), …, k1. + knots.rotate_right(1); + } + Subpath { closed: path.closed, knots, op: path.op } +} + +fn lerp(a: Point, b: Point, t: f64) -> Point { + Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t) +} + +fn cubic(s: &[Point; 4], t: f64) -> Point { + let (a, b, c) = (lerp(s[0], s[1], t), lerp(s[1], s[2], t), lerp(s[2], s[3], t)); + lerp(lerp(a, b, t), lerp(b, c, t), t) +} + +/// Pieces each segment is measured in for [`starting_at`]. +const STEPS: usize = 64; + +/// `path` starting `s` px along it (the type origin moved there): an open path loses the part +/// before `s`, a closed one starts at a new smooth knot there. Unchanged for `s` at or before the +/// start, or (open) at or past the end. +fn starting_at(path: &Subpath, s: f64) -> Subpath { + let segs = path.segments(); + let lengths: Vec = segs + .iter() + .map(|seg| { + (1..=STEPS) + .map(|i| (cubic(seg, (i - 1) as f64 / STEPS as f64), cubic(seg, i as f64 / STEPS as f64))) + .map(|(a, b)| (b.x - a.x).hypot(b.y - a.y)) + .sum() + }) + .collect(); + let total: f64 = lengths.iter().sum(); + if !s.is_finite() || !total.is_finite() || total <= SAME { + return path.clone(); + } + let s = if path.closed { s.rem_euclid(total) } else { s }; + if s <= SAME || s >= total - SAME { + return path.clone(); + } + // The segment and parameter at arc length `s` (linear within a measured piece). + let mut left = s; + let mut at = None; + for (i, (seg, len)) in segs.iter().zip(&lengths).enumerate() { + if left > *len { + left -= len; + continue; + } + let mut walked = 0.0; + for k in 1..=STEPS { + let (t0, t1) = ((k - 1) as f64 / STEPS as f64, k as f64 / STEPS as f64); + let (a, b) = (cubic(seg, t0), cubic(seg, t1)); + let piece = (b.x - a.x).hypot(b.y - a.y); + if walked + piece >= left || k == STEPS { + let f = if piece > 0.0 { ((left - walked) / piece).clamp(0.0, 1.0) } else { 0.0 }; + at = Some((i, t0 + (t1 - t0) * f, *seg)); + break; + } + walked += piece; + } + break; + } + let Some((i, t, seg)) = at else { return path.clone() }; + // de Casteljau: left half p0 a ab m, right half m bc c p3. + let (a, b, c) = (lerp(seg[0], seg[1], t), lerp(seg[1], seg[2], t), lerp(seg[2], seg[3], t)); + let (ab, bc) = (lerp(a, b, t), lerp(b, c, t)); + let m = lerp(ab, bc, t); + let n = path.knots.len(); + if n == 0 { + return path.clone(); + } + let next = (i + 1) % n; + let mut knots = Vec::with_capacity(n + 1); + knots.push(Knot { anchor: m, in_ctrl: if path.closed { ab } else { m }, out_ctrl: bc, smooth: path.closed }); + let count = if path.closed { n } else { n - next }; + for r in 0..count { + let j = (next + r) % n; + let Some(mut k) = path.knots.get(j).copied() else { continue }; + if j == next { + k.in_ctrl = c; + } + if path.closed && j == i { + k.out_ctrl = a; + } + knots.push(k); + } + Subpath { closed: path.closed, knots, op: path.op } +} + +/// The path a frame is written with for `TextShape::Path { path, start, flip }`: flipped type is +/// the path run backwards and a moved start the path from there on (exactly how PhotoCraft lays +/// type on a path out), so Photoshop, which starts at the path's beginning, sets the same type. +pub(crate) fn frame_path(path: &Subpath, start: f32, flip: bool) -> Subpath { + let path = if flip { reversed(path) } else { path.clone() }; + starting_at(&path, f64::from(start)) +} + +fn real(v: f64) -> E { + E::Real(v) +} + +/// The segment list (`/1 /0`) Photoshop reads `path` from: on a closed path the type starts at +/// the last anchor, so the first knot goes last. +fn segment_list(path: &Subpath) -> Option> { + let mut knots = path.knots.clone(); + if path.closed && !knots.is_empty() { + knots.rotate_left(1); + } + let segs = Subpath { closed: path.closed, knots, op: path.op }.segments(); + let out: Vec = segs.iter().flatten().flat_map(|p| [real(p.x), real(p.y)]).collect(); + (!out.is_empty() && segs.iter().flatten().all(|p| p.x.is_finite() && p.y.is_finite())).then_some(out) +} + +/// Whether two type paths are the same within [`SAME`] (the smooth flags aside). +fn same_path(a: &Subpath, b: &Subpath) -> bool { + a.closed == b.closed + && a.knots.len() == b.knots.len() + && a.knots.iter().zip(&b.knots).all(|(p, q)| same(p.anchor, q.anchor) && same(p.in_ctrl, q.in_ctrl) && same(p.out_ctrl, q.out_ctrl)) +} + +/// The point-text frame Photoshop writes (`<< /0 << /2 << /6 [ -1.0 -1.0 ] /11 << /4 -1 /7 false +/// >> >> >> >>`; also what [`crate::psd`] pads the frame list with). +pub(crate) fn point_frame() -> E { + let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]); + let data = E::Dict(vec![("6".into(), E::Array(vec![real(-1.0), real(-1.0)])), ("11".into(), kind)]); + E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]) +} + +/// A text box's frame as Photoshop writes it (`text/paragraph-box-left.psd`): the rectangle as +/// four straight segments from its top left, clockwise. +fn box_frame(x: f64, y: f64, w: f64, h: f64) -> E { + let c = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]; + let pts: Vec = + c.windows(2).flat_map(|p| p.iter().flat_map(|&(a, b)| [(a, b), (a, b)]).collect::>()).flat_map(|(a, b)| [real(a), real(b)]).collect(); + let kind = E::Dict(vec![("4".into(), E::Int(-2)), ("18".into(), real(-2.0)), ("22".into(), real(0.025))]); + let data = E::Dict(vec![("0".into(), E::Int(BOX_FRAME)), ("6".into(), E::Array(vec![real(-2.0), real(-2.0)])), ("11".into(), kind)]); + E::Dict(vec![("0".into(), E::Dict(vec![("1".into(), E::Dict(vec![("0".into(), E::Array(pts))])), ("2".into(), data)]))]) +} + +/// Brings the frame list entry `entry` (`<< /0 frame >>`) of a type layer up to date with its +/// `shape`: type on a path gets a path frame for its path (left byte for byte when it already +/// holds that path, e.g. Photoshop's own after an unedited save), and a layer no longer on a path +/// loses its path frame (else it would reopen on the path). +pub(crate) fn update_frame(entry: &mut E, shape: &TextShape) { + let frame = entry.get("0"); + let kind = frame.map_or(0, frame_kind); + match shape { + TextShape::Path { path, start, flip } => { + let path = frame_path(path, *start, *flip); + if kind == PATH_FRAME && frame.and_then(|f| read(f).ok()).is_some_and(|p| same_path(&p, &path)) { + return; + } + let Some(list) = segment_list(&path) else { return }; + // Keep the other keys of a path frame; anything else becomes one. + let mut data = match frame.and_then(|f| f.get("2")) { + Some(d @ E::Dict(_)) if kind == PATH_FRAME => d.clone(), + _ => E::Dict(vec![("6".into(), E::Array(vec![real(-1.0), real(-1.0)]))]), + }; + data.set("0", E::Int(PATH_FRAME)); + // The path is written in text space. + data.set("2", E::Array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0].map(real).to_vec())); + let mut frame = match frame { + Some(f @ E::Dict(_)) if kind == PATH_FRAME => f.clone(), + _ => E::dict(), + }; + frame.set("1", E::Dict(vec![("0".into(), E::Array(list))])); + frame.set("2", data); + *entry = E::Dict(vec![("0".into(), frame)]); + } + // Leave unreadable path frames unchanged. They were not imported as detached paths. + _ if kind != PATH_FRAME || frame.is_none_or(|f| read(f).is_err()) => {} + TextShape::Box { x, y, width, height } => *entry = box_frame(f64::from(*x), f64::from(*y), f64::from(*width), f64::from(*height)), + TextShape::Point => *entry = point_frame(), + // Area type saves as a text box over its path's bounds, as its EngineData does. + TextShape::Area { path } => { + *entry = match crate::area::path_bounds(path) { + Some((x0, y0, x1, y1)) => box_frame(x0, y0, x1 - x0, y1 - y0), + None => point_frame(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine_data::parse; + + fn frame(src: &str) -> E { + parse(src.as_bytes()).unwrap() + } + + /// The frame of `text/path-circle.psd` (Photoshop 2026). + const CIRCLE: &str = "<< /1 << /0 [ 32.0 128.0 32.0 74.98067 74.98067 32.0 128.0 32.0 128.0 32.0 181.01933 32.0 224.0 74.98067 224.0 128.0 224.0 128.0 224.0 181.01933 181.01933 224.0 128.0 224.0 128.0 224.0 74.98067 224.0 32.0 181.01933 32.0 128.0 ] >> /2 << /0 2 /2 [ 1.0 0.0 0.0 1.0 -32.0 -32.0 ] /6 [ -1.0 -1.0 ] >> >>"; + /// The frame of `text/path-wave-open.psd` (Photoshop 2026). + const WAVE: &str = "<< /1 << /0 [ 16.0 100.0 46.0 40.0 120.0 40.0 160.0 80.0 160.0 80.0 200.0 120.0 274.0 100.0 304.0 60.0 ] >> /2 << /0 2 /2 [ 1.0 0.0 0.0 1.0 -16.0 -52.12246 ] /6 [ -1.0 -1.0 ] >> >>"; + + #[test] + fn photoshop_path_frames_read_into_text_space() { + let wave = read(&frame(WAVE)).unwrap(); + assert!(!wave.closed); + assert_eq!(wave.knots.len(), 3); + let p = |x: f64, y: f64| Point::new(x - 16.0, y - 52.12246); + let k = &wave.knots; + assert!(same(k[0].anchor, p(16.0, 100.0)) && same(k[0].in_ctrl, p(16.0, 100.0)) && same(k[0].out_ctrl, p(46.0, 40.0))); + assert!(same(k[1].in_ctrl, p(120.0, 40.0)) && same(k[1].anchor, p(160.0, 80.0)) && same(k[1].out_ctrl, p(200.0, 120.0)) && k[1].smooth); + assert!(same(k[2].in_ctrl, p(274.0, 100.0)) && same(k[2].anchor, p(304.0, 60.0))); + + let circle = read(&frame(CIRCLE)).unwrap(); + assert!(circle.closed); + assert_eq!(circle.knots.len(), 4); + // The type starts at the bottom anchor (Photoshop's glyph 0) and runs to the left one. + assert!(same(circle.knots[0].anchor, Point::new(96.0, 192.0))); + assert!(same(circle.knots[1].anchor, Point::new(0.0, 96.0))); + assert!(circle.knots.iter().all(|k| k.smooth)); + } + + #[test] + fn written_frames_read_back() { + for src in [CIRCLE, WAVE] { + let path = read(&frame(src)).unwrap(); + let mut entry = E::Dict(vec![("0".into(), frame(src))]); + let before = entry.clone(); + update_frame(&mut entry, &TextShape::Path { path: path.clone(), start: 0.0, flip: false }); + assert_eq!(entry, before, "an unchanged path keeps Photoshop's frame"); + let mut moved = path.clone(); + for k in &mut moved.knots { + *k = k.transform(&Affine::translate(5.0, -3.0)); + } + update_frame(&mut entry, &TextShape::Path { path: moved.clone(), start: 0.0, flip: false }); + let back = read(entry.get("0").unwrap()).unwrap(); + assert!(same_path(&back, &moved), "{back:?}"); + assert_eq!(entry.path(&["0", "2", "6"]), before.path(&["0", "2", "6"]), "the frame's other keys stay"); + } + } + + /// Flipped type and a moved start are written into the path, which lays the type out the same. + #[test] + fn flip_and_start_are_baked_into_the_frame() { + use crate::warp::Warp; + for src in [CIRCLE, WAVE] { + let path = read(&frame(src)).unwrap(); + for (start, flip) in [(40.0, false), (0.0, true), (25.0, true)] { + let shape = TextShape::Path { path: path.clone(), start, flip }; + let baked = TextShape::Path { path: frame_path(&path, start, flip), start: 0.0, flip: false }; + let (a, b) = (Warp::on_path(&shape).unwrap(), Warp::on_path(&baked).unwrap()); + for x in [0.0, 10.0, 50.0, 120.0] { + for y in [-12.0, 0.0, 4.0] { + let (p, q) = (a.apply(x, y), b.apply(x, y)); + assert!((p.0 - q.0).hypot(p.1 - q.1) < 0.5, "{src:.10} start {start} flip {flip} at ({x}, {y}): {p:?} vs {q:?}"); + } + } + } + } + } + + #[test] + fn hostile_frames_are_errors() { + for src in [ + "<< /2 << /0 2 >> >>", + "<< /1 << /0 [ ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 1.0 2.0 3.0 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 (x) 3 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 1e999 3 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 3 3 ] >> /2 << /0 2 /2 [ 0 0 0 0 0 0 ] >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 3 3 ] >> /2 << /0 2 /2 [ 1 0 0 ] >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 3 3 ] >> /2 << /0 2 /2 [ 1e300 0 0 1e300 0 0 ] >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 3 3 50 50 60 60 70 70 80 80 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 5 5 5 5 5 5 5 5 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 (path) >> /2 << /0 2 >> >>", + ] { + assert!(read(&frame(src)).is_err(), "{src}"); + } + let many = format!("<< /1 << /0 [ {} ] >> /2 << /0 2 >> >>", "0 0 1 1 2 2 0 0 ".repeat(MAX_SEGMENTS + 1)); + assert!(read(&frame(&many)).is_err()); + } + + fn d(items: Vec<(&str, E)>) -> E { + E::Dict(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) + } + + /// A Photoshop-shaped `Txt2` block with one point-text object for `text` (`\r` breaks, trailing + /// `\r`) drawn in frame 0. + fn photoshop_txt2(text: &str) -> Vec { + photoshop_txt2_in(text, point_frame()) + } + + /// [`photoshop_txt2`] with the frame list entry `frame`. + fn photoshop_txt2_in(text: &str, frame: E) -> Vec { + photoshop_txt2_of(vec![photoshop_object(text, 0)], vec![frame]) + } + + /// A Photoshop-shaped `Txt2` block holding `objects` and the frame list `frames`. + fn photoshop_txt2_of(objects: Vec, frames: Vec) -> Vec { + crate::engine_data::write_bare(&[ + ("98".into(), d(vec![("0", E::Int(14))])), + ("0".into(), d(vec![("8", d(vec![("0", E::Array(frames))]))])), + ("1".into(), d(vec![("1", E::Array(objects))])), + ]) + } + + /// A Photoshop-shaped text object for `text` drawn in frame `at`. + fn photoshop_object(text: &str, at: i64) -> E { + let len = text.encode_utf16().count() as i64; + let run = || { + d(vec![("0", d(vec![("0", d(vec![("0", E::String(String::new())), ("6", d(vec![("0", E::Int(0)), ("1", E::Real(12.0))]))]))])), ("1", E::Int(len))]) + }; + let model = d(vec![("0", E::String(text.into())), ("5", d(vec![("0", E::Array(vec![run()]))])), ("6", d(vec![("0", E::Array(vec![run()]))]))]); + let strike = d(vec![("99", E::Name("PC".into())), ("6", E::Array(vec![d(vec![("99", E::Name("F".into())), ("10", E::Int(at))])]))]); + let view = d(vec![ + ("0", E::Array(vec![d(vec![("0", E::Int(at))])])), + ("1", d(vec![("0", E::Array(vec![d(vec![("0", d(vec![("1", E::Int(1))])), ("1", E::Int(len))])]))])), + ("2", E::Array(vec![strike])), + ]); + d(vec![("0", model), ("1", view)]) + } + + fn layer(text: &str, shape: TextShape) -> photocraft_doc::TextLayer { + use photocraft_doc::text::{CharStyle, TextRun}; + let mut t = photocraft_doc::TextLayer { + text: text.into(), + runs: vec![TextRun { len: text.len(), style: CharStyle { size_pt: 12.0, ..Default::default() } }], + shape, + transform: Affine::translate(32.0, 20.0), + ..Default::default() + }; + t.sync_summary(); + t + } + + /// A layer read back, with what [`crate::psd::apply_txt2_path`] said. + type Opened = (photocraft_doc::TextLayer, Result); + + /// Saves `layers` (object `i` each) into a file whose `Txt2` is `prev`, then reads each back: + /// its `TySh` and what [`crate::psd::apply_txt2_path`] makes of the saved `Txt2`. + fn save_and_open(layers: &[&photocraft_doc::TextLayer], prev: &[u8]) -> (Vec, Vec) { + let objects: Vec<(i32, &photocraft_doc::TextLayer)> = layers.iter().enumerate().map(|(i, l)| (i as i32, *l)).collect(); + let txt2 = crate::psd::build_txt2(&objects, Some(prev)); + let parsed = crate::psd::parse_txt2(&txt2).unwrap(); + let back = objects + .iter() + .map(|(i, l)| { + let tysh = crate::psd::set_text_index(&crate::psd::build_tysh(l, 72.0, None), *i).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &parsed); + let applied = crate::psd::apply_txt2_path(&mut back, &tysh, &parsed); + crate::psd::apply_txt2_frame(&mut back, &tysh, &parsed); + (back, applied) + }) + .collect(); + (txt2, back) + } + + /// #7962: type on a path survives a PSD save and open, for the file's own text object and for + /// a layer the file had no object for; a moved start and flipped type come back laid out the + /// same, in the path; a second save changes nothing. + #[test] + fn path_text_round_trips_through_tysh_and_txt2() { + let circle = read(&frame(CIRCLE)).unwrap(); + let wave = read(&frame(WAVE)).unwrap(); + let a = layer("On a circle", TextShape::Path { path: circle.clone(), start: 0.0, flip: false }); + let b = layer("Along a wave", TextShape::Path { path: wave.clone(), start: 30.0, flip: true }); + let prev = photoshop_txt2("On a circle\r"); + let (txt2, back) = save_and_open(&[&a, &b], &prev); + for ((l, applied), (want, start, flip)) in back.iter().zip([(&circle, 0.0, false), (&wave, 30.0, true)]) { + assert_eq!(applied, &Ok(true), "{}", l.text); + let TextShape::Path { path, start: s, flip: f } = &l.shape else { panic!("{}: {:?}", l.text, l.shape) }; + assert_eq!((*s, *f), (0.0, false)); + assert!(same_path(path, &frame_path(want, start, flip)), "{}: {path:?}", l.text); + assert_eq!(l.transform, Affine::translate(32.0, 20.0), "the frame's matrix is not applied twice"); + } + let (again, _) = save_and_open(&[&back[0].0, &back[1].0], &txt2); + assert_eq!(again, txt2, "a re-save of the opened file changes nothing"); + // Off its path, the layer opens as point text again. + let detached = layer("On a circle", TextShape::Point); + let (_, back) = save_and_open(&[&detached], &txt2); + assert_eq!(back[0].1, Ok(false)); + assert_eq!(back[0].0.shape, TextShape::Point); + } + + /// A malformed path frame leaves the layer as EngineData describes it, with the reason. + #[test] + fn a_malformed_path_frame_keeps_the_engine_data_shape() { + let a = layer("Hostile", TextShape::Box { x: 0.0, y: 0.0, width: 50.0, height: 20.0 }); + let tysh = crate::psd::set_text_index(&crate::psd::build_tysh(&a, 72.0, None), 0).unwrap(); + for bad in ["<< /1 << /0 [ 1 2 3 ] >> /2 << /0 2 >> >>", "<< /1 << /0 [ 0 0 1 1 2 2 3 3 ] >> /2 << /0 2 /2 [ 0 0 0 0 0 0 ] >> >>"] { + let entry = E::Dict(vec![("0".into(), frame(bad))]); + let txt2 = crate::psd::parse_txt2(&photoshop_txt2_in("Hostile\r", entry)).unwrap(); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + let before = l.shape.clone(); + assert!(crate::psd::apply_txt2_path(&mut l, &tysh, &txt2).is_err(), "{bad}"); + assert_eq!(l.shape, before); + } + } + + /// A layer taken off its path doesn't keep its path frame (it would reopen on the path). + #[test] + fn a_layer_off_its_path_loses_the_path_frame() { + let mut entry = E::Dict(vec![("0".into(), frame(CIRCLE))]); + update_frame(&mut entry, &TextShape::Point); + assert_eq!(entry, point_frame()); + let mut entry = E::Dict(vec![("0".into(), frame(WAVE))]); + update_frame(&mut entry, &TextShape::Box { x: 1.0, y: 2.0, width: 30.0, height: 40.0 }); + assert_eq!(entry.get("0").map(frame_kind), Some(BOX_FRAME)); + assert_eq!( + entry.path(&["0", "1", "0"]).and_then(numbers).unwrap()[..8], + [1.0, 2.0, 1.0, 2.0, 31.0, 2.0, 31.0, 2.0], + "Photoshop's box outline: corners as straight segments from the top left" + ); + // A point or box frame is left alone. + let mut point = point_frame(); + update_frame(&mut point, &TextShape::Point); + assert_eq!(point, point_frame()); + } + + /// A path frame the import could not read (the layer opened as EngineData's box or point + /// text) is saved back as it was, not replaced by a box or point frame. + #[test] + fn an_unreadable_path_frame_is_kept() { + let two_pieces = "<< /1 << /0 [ 0 0 1 1 2 2 3 3 50 50 60 60 70 70 80 80 ] >> /2 << /0 2 >> >>"; + for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 80.0, height: 80.0 }] { + let mut entry = E::Dict(vec![("0".into(), frame(two_pieces))]); + let before = entry.clone(); + update_frame(&mut entry, &shape); + assert_eq!(entry, before, "{shape:?}"); + } + } + + /// The frame number the object at `index` of the saved `txt2` names, and that frame. + fn saved_frame(txt2: &[u8], index: usize) -> (i64, E) { + let t = crate::psd::parse_txt2(txt2).unwrap(); + let object = &t.path(&["1", "1"]).and_then(E::as_array).unwrap()[index]; + let at = object.path(&["1", "0"]).and_then(E::as_array).unwrap()[0].get("0").and_then(E::as_i64).unwrap(); + (at, t.path(&["0", "8", "0"]).and_then(E::as_array).unwrap()[at as usize].clone()) + } + + /// An edited layer whose object the save can't refit keeps drawing in that object's own frame, + /// not in the frame its slot number names, which is the path layer's here. + #[test] + fn an_object_rebuilt_from_a_template_keeps_its_own_frame() { + let circle = read(&frame(CIRCLE)).unwrap(); + let on_path = layer("On a circle", TextShape::Path { path: circle, start: 0.0, flip: false }); + // Object 1's style run has a length that is no count, so it can't be refitted. + let mut broken = photoshop_object("Old\r", 0); + let run = d(vec![("0", d(vec![("0", d(vec![("0", E::String(String::new()))]))])), ("1", E::String("x".into()))]); + broken.get_mut("0").unwrap().set("6", d(vec![("0", E::Array(vec![run]))])); + let path_frame = E::Dict(vec![("0".into(), frame(CIRCLE))]); + let prev = photoshop_txt2_of(vec![photoshop_object("On a circle\r", 1), broken], vec![point_frame(), path_frame.clone()]); + let (txt2, back) = save_and_open(&[&on_path, &layer("New", TextShape::Point)], &prev); + assert_eq!(saved_frame(&txt2, 1).0, 0, "the rebuilt object draws in its own frame"); + assert_eq!(saved_frame(&txt2, 0), (1, path_frame)); + assert_eq!(back[0].1, Ok(true)); + assert!(matches!(back[0].0.shape, TextShape::Path { .. }), "{:?}", back[0].0.shape); + } + + /// A new layer whose slot number is the frame another object is drawn in gets a frame of its + /// own: neither layer takes the other's shape, on this save or the next. + #[test] + fn a_new_object_gets_a_frame_no_other_object_is_drawn_in() { + let circle = read(&frame(CIRCLE)).unwrap(); + let on_path = layer("On a circle", TextShape::Path { path: circle, start: 0.0, flip: false }); + let path_frame = E::Dict(vec![("0".into(), frame(CIRCLE))]); + let prev = photoshop_txt2_of(vec![photoshop_object("On a circle\r", 1)], vec![point_frame(), path_frame.clone()]); + let new = layer("New", TextShape::Point); + let (txt2, back) = save_and_open(&[&on_path, &new], &prev); + assert_eq!(saved_frame(&txt2, 1), (2, point_frame()), "a frame past the file's own"); + assert_eq!(saved_frame(&txt2, 0), (1, path_frame)); + assert_eq!(back[0].1, Ok(true)); + assert_eq!((&back[1].1, &back[1].0.shape), (&Ok(false), &TextShape::Point)); + // The path layer taken off its path on the next save loses its path frame. + let (again, _) = save_and_open(&[&layer("On a circle", TextShape::Point), &new], &txt2); + assert_eq!(saved_frame(&again, 0), (1, point_frame())); + } + + /// A rebuilt object whose old frame number is past the frame list doesn't name a frame the + /// block lacks (Photoshop refuses such files, #7698). + #[test] + fn a_rebuilt_object_never_names_a_missing_frame() { + let mut broken = photoshop_object("Old\r", 70_000); + let run = d(vec![("0", d(vec![("0", d(vec![("0", E::String(String::new()))]))])), ("1", E::String("x".into()))]); + broken.get_mut("0").unwrap().set("6", d(vec![("0", E::Array(vec![run]))])); + let path_frame = E::Dict(vec![("0".into(), frame(CIRCLE))]); + let prev = photoshop_txt2_of(vec![photoshop_object("On a circle\r", 1), broken], vec![point_frame(), path_frame.clone()]); + let on_path = layer("On a circle", TextShape::Path { path: read(&frame(CIRCLE)).unwrap(), start: 0.0, flip: false }); + let (txt2, _) = save_and_open(&[&on_path, &layer("New", TextShape::Point)], &prev); + assert_eq!(saved_frame(&txt2, 1), (2, point_frame())); + assert_eq!(saved_frame(&txt2, 0), (1, path_frame)); + } + + /// An object the file keeps that names a frame past the frame list doesn't grow the list (to + /// 65536 point frames here). + #[test] + fn a_kept_object_naming_a_far_frame_adds_no_frames() { + let prev = photoshop_txt2_of(vec![photoshop_object("Far\r", crate::psd::MAX_TEXT_INDEX.into())], vec![point_frame()]); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &layer("Far", TextShape::Point))], Some(&prev))).unwrap(); + assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).map(<[E]>::len), Some(1)); + } + + /// A frame the file already has two objects drawn in is left as the file has it, not given + /// the shape of whichever layer comes last. + #[test] + fn a_frame_two_objects_name_is_kept() { + let circle = read(&frame(CIRCLE)).unwrap(); + let on_path = layer("On a circle", TextShape::Path { path: circle, start: 0.0, flip: false }); + let path_frame = E::Dict(vec![("0".into(), frame(CIRCLE))]); + let objects = vec![photoshop_object("On a circle\r", 1), photoshop_object("Point\r", 1)]; + let prev = photoshop_txt2_of(objects, vec![point_frame(), path_frame.clone()]); + let (txt2, _) = save_and_open(&[&on_path, &layer("Point", TextShape::Point)], &prev); + assert_eq!(saved_frame(&txt2, 0), (1, path_frame)); + assert_eq!(saved_frame(&txt2, 1).0, 1); + } + + /// Area type's frame becomes a text box over its path's bounds, as its EngineData describes it. + #[test] + fn an_area_layer_gets_a_box_frame() { + let square = + photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polygon(&[(10.0, 20.0), (50.0, 20.0), (50.0, 80.0), (10.0, 80.0)])]); + let mut entry = E::Dict(vec![("0".into(), frame(CIRCLE))]); + update_frame(&mut entry, &TextShape::Area { path: square }); + assert_eq!(entry, box_frame(10.0, 20.0, 40.0, 60.0)); + } +} diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index 6f953f1cec..b28949a22f 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -87,9 +87,9 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { let normal_p = res.get("TheNormalParagraphSheet").and_then(E::as_i64).and_then(sheet_index).unwrap_or(0); let empty = E::dict(); let base_data = sset.get(normal_c).and_then(|s| s.get("StyleSheetData")); - let normal_char = char_style(None, base_data.unwrap_or(&empty), &fonts, k); + let normal_char = char_style(&[], base_data.unwrap_or(&empty), &fonts, k); let base_props = pset.get(normal_p).and_then(|s| s.get("Properties")); - let normal_para = para_style(None, base_props.unwrap_or(&empty), k); + let normal_para = para_style(&[], base_props.unwrap_or(&empty), k); let mut out = PsdStyleSheets { normal_char, normal_para, ..Default::default() }; for (i, s) in sset.iter().enumerate() { if i == normal_c { @@ -97,7 +97,7 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { } let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string(); let data = s.get("StyleSheetData").unwrap_or(&empty); - out.char_sheets.push((i, name, char_style(base_data, data, &fonts, k))); + out.char_sheets.push((i, name, char_style(&[base_data], data, &fonts, k))); } for (i, s) in pset.iter().enumerate() { if i == normal_p { @@ -106,8 +106,8 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string(); let props = s.get("Properties").unwrap_or(&empty); let dss = s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(sheet_index).filter(|i| *i != normal_c); - let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(base_data, d.get("StyleSheetData").unwrap_or(&empty), &fonts, k)); - out.para_sheets.push((i, name, para_style(base_props, props, k), cs)); + let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(&[base_data], d.get("StyleSheetData").unwrap_or(&empty), &fonts, k)); + out.para_sheets.push((i, name, para_style(&[base_props], props, k), cs)); } let snames = names(res.get("StyleSheetSet")); let pnames = names(res.get("ParagraphSheetSet")); diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index bca52e38cf..4f32e9941e 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -76,8 +76,13 @@ pub struct Coverage { } impl Coverage { - pub fn new(width: usize, height: usize) -> Self { - Self { width, height, acc: vec![0.0; width * height + 2] } + /// A zeroed accumulator, or `None` when its bytes cannot be reserved. + pub fn try_new(width: usize, height: usize) -> Option { + let n = width.checked_mul(height)?.checked_add(2)?; + let mut acc = Vec::new(); + acc.try_reserve_exact(n).ok()?; + acc.resize(n, 0.0); + Some(Self { width, height, acc }) } /// Adds a line segment (pixel coordinates, y down). @@ -148,15 +153,18 @@ impl Coverage { } /// Resolves the accumulation into per-pixel coverage in 0..=1. - pub fn finish(self) -> Vec { + /// The accumulated coverage, or `None` when the output cannot be reserved. The input + /// accumulator is still live while this builds, so both count toward a caller's budget. + pub fn finish(self) -> Option> { let n = self.width * self.height; - let mut out = Vec::with_capacity(n); + let mut out = Vec::new(); + out.try_reserve_exact(n).ok()?; let mut sum = 0.0f32; for v in &self.acc[..n] { sum += v; out.push(sum.abs().min(1.0)); } - out + Some(out) } } @@ -240,9 +248,9 @@ mod tests { #[test] fn square_area_exact() { - let mut c = Coverage::new(10, 10); + let mut c = Coverage::try_new(10, 10).unwrap(); rect_to(&mut c, &Xform::IDENTITY, 2.25, 3.5, 6.75, 8.0); - let v = c.finish(); + let v = c.finish().unwrap(); assert!((sum(&v) - 4.5 * 4.5).abs() < 1e-3, "{}", sum(&v)); assert_eq!(v[5 * 10 + 4], 1.0); assert_eq!(v[0], 0.0); @@ -250,13 +258,13 @@ mod tests { #[test] fn triangle_area_and_clipping() { - let mut c = Coverage::new(8, 8); + let mut c = Coverage::try_new(8, 8).unwrap(); // Right triangle partly outside on the left and bottom. let pts = [(-4.0, 2.0), (6.0, 2.0), (-4.0, 12.0)]; for i in 0..3 { c.line(pts[i], pts[(i + 1) % 3]); } - let v = c.finish(); + let v = c.finish().unwrap(); // Visible part: region x in [0,6], y in [2,8] under the hypotenuse x + y <= 8. // Area = ∫_{y=2}^{8} (8 - y) dy = 18. assert!((sum(&v) - 18.0).abs() < 1e-3, "{}", sum(&v)); @@ -266,7 +274,7 @@ mod tests { #[test] fn circle_area_via_pen() { use skrifa::outline::OutlinePen; - let mut c = Coverage::new(40, 40); + let mut c = Coverage::try_new(40, 40).unwrap(); { let mut p = Pen::new(&mut c, Xform([1.0, 0.0, 0.0, -1.0, 20.0, 20.0])); // Cubic circle approximation, r = 15. @@ -278,7 +286,7 @@ mod tests { p.curve_to(k, -r, r, -k, r, 0.0); p.close(); } - let a = sum(&c.finish()); + let a = sum(&c.finish().unwrap()); let exact = std::f32::consts::PI * 225.0; assert!((a - exact).abs() / exact < 0.005, "{a} vs {exact}"); } diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 7128085dc2..c7cd3960b4 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -21,6 +21,42 @@ pub const FAUX_ITALIC_DEG: f32 = 12.0; pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; +/// Cumulative coverage-pixel work budget across all colour and faux-bold passes of one render. +const MAX_PASS_PIXELS: u64 = MAX_PIXELS * 4; +/// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the +/// accumulator and the output surface are both `stride` deep, and a faux-bold pass keeps one more +/// coverage buffer live, so a CMYK bold render reserves several times what a plain grey one does +/// for the same pixels. +const MAX_RASTER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; + +/// Whether a `w` x `h` render's peak working bytes fit `max_bytes`: the f32 accumulator and the +/// output surface (both `stride` deep), plus the coverage buffers live at the peak. A plain pass +/// peaks inside `Coverage::finish`, which holds its accumulator and the vector it builds beside +/// it; a faux-bold pass peaks one buffer higher, since the resolved plain coverage is kept while +/// the bold pass resolves into it. `false` when the arithmetic overflows, so an absurd size is +/// refused rather than wrapping. +fn fits_raster_budget(w: usize, h: usize, stride: usize, sample: SampleType, bold: bool, max_bytes: u64) -> bool { + let f32s = size_of::() as u64; + let peak = (w as u64).checked_mul(h as u64).and_then(|px| { + let deep = px.checked_mul(stride as u64)?.checked_mul(f32s + sample.bytes() as u64)?; + let coverage = px.checked_mul(if bold { 3 } else { 2 })?.checked_mul(f32s)?; + deep.checked_add(coverage) + }); + peak.is_some_and(|b| b <= max_bytes) +} + +/// A zeroed buffer, or `None` when its bytes cannot be reserved. +fn zeroed(len: usize) -> Option> { + let mut v = Vec::new(); + v.try_reserve_exact(len).ok()?; + v.resize(len, 0.0); + Some(v) +} + +/// What a refused or unrenderable layout returns. +fn nothing(format: PixelFormat) -> Rendered { + Rendered { surface: Surface::new(format), rect: Rect::new(0, 0, 0, 0) } +} /// Maps raw area-coverage to the opacity Photoshop's anti-alias modes imply. Exponents above 1 thin /// the fringe (sharper); below 1 widen it (stronger smoothing). @@ -101,6 +137,54 @@ fn is_bold(layout: &TextLayout, g: &PlacedGlyph) -> bool { layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold) || face_bold } +fn glyph_is_placed(layout: &TextLayout, g: &PlacedGlyph, warp: Option<&Warp>) -> bool { + let Some(face) = layout.faces.get(g.face as usize) else { + return false; + }; + let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else { + return false; + }; + if font.outline_glyphs().get(GlyphId::new(g.id)).is_none() { + return false; + } + if let Some(w) = warp { + let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); + if matches!(w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)), Placement::Hidden) { + return false; + } + } + true +} + +/// 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| { + if !(c.alpha.is_finite() && c.alpha > 0.0) { + return; + } + match colors.iter_mut().find(|(existing, _)| *existing == c) { + Some((_, b)) => *b |= bold, + None => colors.push((c, bold)), + } + }; + for g in &layout.glyphs { + 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)); + } + } + for d in &layout.decorations { + let Some(st) = layout.styles.get(d.style as usize) else { continue }; + record(st.color, false); + } + colors +} + fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>, pass: Pass) { for g in &layout.glyphs { let st = &layout.styles[g.style as usize]; @@ -253,6 +337,14 @@ fn rect_from_bounds([x0, y0, x1, y1]: [f64; 4]) -> Rect { Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32) } +/// Text-space bounds of the drawn glyph outlines and decorations, unrounded (`[x0, y0, x1, y1]`), +/// or `None` when nothing is drawn. +pub fn ink_bounds(layout: &TextLayout) -> Option<[f64; 4]> { + let mut b = Bounds::default(); + draw(layout, &Xform::IDENTITY, &mut b, None, None); + b.rect +} + /// [`ink_rect`] of warped text. pub fn ink_rect_warped(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Rect { let mut b = Bounds::default(); @@ -268,6 +360,19 @@ pub fn rasterize(layout: &TextLayout, transform: &Affine, format: PixelFormat, a /// [`rasterize`] with the glyph outlines bent by `warp` (Type › Warp Text). pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFormat, antialias: AntiAlias, warp: Option<&Warp>) -> Rendered { + rasterize_passes(layout, transform, format, antialias, warp, true) +} + +/// [`rasterize_warped`] with every colour's coverage either confined to that colour's own ink +/// (`per_colour`) or spanning the whole text, the oracle the tests compare it with (#8602). +pub(crate) fn rasterize_passes( + layout: &TextLayout, + transform: &Affine, + format: PixelFormat, + antialias: AntiAlias, + warp: Option<&Warp>, + per_colour: bool, +) -> Rendered { let format = PixelFormat { alpha: true, ..format }; let rect = ink_rect_warped(layout, transform, warp); let (w, h) = (rect.width() as usize, rect.height() as usize); @@ -275,33 +380,71 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return Rendered { surface, rect: Rect::new(0, 0, 0, 0) }; } + let colors = visible_color_passes(layout, warp); + if colors.is_empty() { + return Rendered { surface, rect: Rect::new(0, 0, 0, 0) }; + } let n = format.mode.color_channels(); let stride = n + 1; - // Premultiplied accumulation. - let mut acc = vec![0.0f32; w * h * stride]; - let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m)); - let mut colors: Vec = Vec::new(); - for st in &layout.styles { - if !colors.contains(&st.color) { - colors.push(st.color); - } + 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); } - for c in &colors { - let mut cov = Coverage::new(w, h); - draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain); - let mut cov = cov.finish(); - if layout.glyphs.iter().any(|g| is_bold(layout, g)) { + // Premultiplied accumulation. + let Some(mut acc) = zeroed(w * h * stride) else { + return nothing(format); + }; + 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, color_bold) in &colors { + // Rasterise each colour only over its own glyphs (every offset of faux bold 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_pass(layout, &xf, &mut b, Some(&c), warp, Pass::All); + b.rect.map(rect_from_bounds).unwrap_or(Rect::EMPTY).intersect(&whole) + } else { + whole + }; + if sub.is_empty() { + continue; + } + let (sw, sh) = (sub.width() as usize, sub.height() as usize); + let passes = 1 + if color_bold { BOLD_OFFSETS.len() } else { 0 }; + let Some(cost) = (sw as u64).checked_mul(sh as u64).and_then(|px| px.checked_mul(passes as u64)) else { + return nothing(format); + }; + let Some(total) = pass_pixels.checked_add(cost).filter(|&t| t <= MAX_PASS_PIXELS) else { + return nothing(format); + }; + pass_pixels = total; + let xf = Xform([1.0, 0.0, 0.0, 1.0, -sub.x0 as f64, -sub.y0 as f64]).mul(&xf); + let Some(mut cov) = Coverage::try_new(sw, sh) else { + return nothing(format); + }; + draw_pass(layout, &xf, &mut cov, Some(&c), warp, Pass::Plain); + let Some(mut cov) = cov.finish() else { + return nothing(format); + }; + if color_bold { for k in 0..BOLD_OFFSETS.len() { - let mut bc = Coverage::new(w, h); - draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k)); - for (a, b) in cov.iter_mut().zip(bc.finish()) { + let Some(mut bc) = Coverage::try_new(sw, sh) else { + return nothing(format); + }; + draw_pass(layout, &xf, &mut bc, Some(&c), warp, Pass::Bold(k)); + let Some(bold_cov) = bc.finish() else { + return nothing(format); + }; + for (a, b) in cov.iter_mut().zip(bold_cov) { *a = a.max(b); } } } - let comps = color_in(&format, c); + let comps = color_in(&format, &c); let a = c.alpha.clamp(0.0, 1.0); - composite(&mut acc, &cov, w, stride, &comps, a, antialias); + composite(&mut acc, w, sub, &cov, stride, &comps, a, antialias); } // Unpremultiply. unpremultiply(&mut acc, n, stride, format.sample); @@ -310,12 +453,19 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo Rendered { surface, rect } } -/// Composites one colour's coverage over the premultiplied accumulator, after [`shape_antialias_coverage`]. -/// A pixel's maths reads and writes only its own slots, so the rows are done in any -/// order — one per thread — for exactly the same bytes. -fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) { +/// Composites one colour's coverage, covering `sub` of a `width`-pixel-wide premultiplied +/// accumulator, after [`shape_antialias_coverage`]. A pixel's maths reads and writes only its own +/// slots, so the rows are done in any order — one per thread — for exactly the same bytes. +#[allow(clippy::too_many_arguments)] +fn composite(acc: &mut [f32], width: usize, sub: Rect, cov: &[f32], stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) { let n = comps.len(); + let (x0, x1) = (sub.x0.max(0) as usize * stride, sub.x1.max(0) as usize * stride); + let (y0, sw, sh) = (sub.y0.max(0) as usize, sub.width() as usize, sub.height() as usize); + if sw == 0 || sh == 0 { + return; + } let row = |acc: &mut [f32], cov: &[f32]| { + let Some(acc) = acc.get_mut(x0..x1) else { return }; for (cv, px) in cov.iter().zip(acc.chunks_exact_mut(stride)) { let cv = shape_antialias_coverage(*cv, antialias); let s = cv * a; @@ -330,9 +480,9 @@ fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: & } }; #[cfg(not(target_arch = "wasm32"))] - acc.par_chunks_mut(width * stride).zip(cov.par_chunks(width)).for_each(|(a, c)| row(a, c)); + acc.par_chunks_mut(width * stride).skip(y0).take(sh).zip(cov.par_chunks(sw)).for_each(|(a, c)| row(a, c)); #[cfg(target_arch = "wasm32")] - acc.chunks_mut(width * stride).zip(cov.chunks(width)).for_each(|(a, c)| row(a, c)); + acc.chunks_mut(width * stride).skip(y0).take(sh).zip(cov.chunks(sw)).for_each(|(a, c)| row(a, c)); } /// Divides the premultiplied accumulator back to straight colour. Each pixel stands alone. @@ -445,7 +595,8 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> #[cfg(test)] mod tests { - use super::{MAX_PIXELS, rect_from_bounds}; + use super::{MAX_PIXELS, fits_raster_budget, rect_from_bounds, zeroed}; + use photocraft_color::SampleType; use photocraft_geom::Rect; #[test] @@ -466,4 +617,25 @@ mod tests { assert_eq!(rect_from_bounds(bounds), first, "bounds: {bounds:?}"); } } + + /// #4979: the pixel cap says nothing about bytes, so the peak is admitted from the channel + /// depth of the accumulator and surface, the sample size and the faux-bold coverage buffers. + /// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 2 x 4 = 18 bytes a pixel live. + #[test] + fn the_byte_budget_follows_channels_depth_and_faux_bold() { + let px = 100 * 100; + assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18)); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18 - 1), "the allowance is exact"); + + // The same pixels cost more with more channels, a wider sample, or a bold pass. + assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 18), "CMYK is deeper"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 18), "f32 output is wider"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 18), "bold holds one more buffer"); + assert!(fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 22), "2 x 5 + 3 x 4 bytes a pixel"); + + // A size whose peak overflows is refused rather than wrapping to a small number. + assert!(!fits_raster_budget(usize::MAX, usize::MAX, 5, SampleType::F32, true, u64::MAX)); + // Nothing is reserved for a buffer that cannot be served. + assert!(zeroed(usize::MAX).is_none()); + } } diff --git a/crates/text/src/scripts.rs b/crates/text/src/scripts.rs index b6e0aae74f..54120f609b 100644 --- a/crates/text/src/scripts.rs +++ b/crates/text/src/scripts.rs @@ -280,6 +280,15 @@ pub fn covering_face(bytes: &[u8], c: char) -> Option { } } +/// Whether face `index` of `bytes` (a font or collection) maps `c` in its character map. +/// `false` for an out-of-range face or data that doesn't parse. Never panics. +pub fn face_covers(bytes: &[u8], index: u32, c: char) -> bool { + use skrifa::MetadataProvider; + + let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { return false }; + font.charmap().map(c).is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -351,6 +360,10 @@ mod tests { for bad in [&b""[..], b"ttcf", b"ttcf\0\x01\0\0\0\0\0\x02\xff\xff\xff\xff\0\0\0\x10", b"\0\x01\0\0\0\xff"] { assert_eq!(covering_face(bad, 'A'), None); } + assert!(face_covers(inter, 0, 'A')); + assert!(!face_covers(inter, 0, 'ส')); + assert!(!face_covers(inter, 1, 'A'), "a single-face file has no face 1"); + assert!(!face_covers(b"", 0, 'A')); } /// With the real system fonts, each script that has a candidate installed has one that diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 0198aea97a..69897329b7 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -40,25 +40,60 @@ pub struct ServedFont { pub fn parse_manifest(text: &str) -> (Vec, Vec) { let mut fonts = Vec::new(); let mut skipped = Vec::new(); - for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { - let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let raw_fields: Vec<&str> = trimmed.split(" | ").collect(); + let f: Vec<&str> = raw_fields.iter().map(|s| s.trim()).collect(); + let raw_file = raw_fields.get(2).copied().unwrap_or(""); + let valid_file = raw_file == f.get(2).copied().unwrap_or("") && is_relative_path(raw_file); match f.as_slice() { - [family, _style, file, rest @ ..] if !family.is_empty() && is_relative_path(file) => { + [family, _style, file, rest @ ..] if !family.is_empty() && valid_file => { let scripts = rest.first().map(|s| s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()).unwrap_or_default(); let integrity = rest.get(3).and_then(|sha| sri(sha)); fonts.push(ServedFont { family: (*family).to_string(), file: (*file).to_string(), scripts, integrity }); } - [_, _, file, ..] if !is_relative_path(file) => skipped.push(format!("{line}: the file must be a relative path inside the site")), - _ => skipped.push(format!("{line}: expected `family | style | file | …`")), + [_, _, _, ..] if !valid_file => skipped.push(format!("{trimmed}: the file must be a relative path inside the site")), + _ => skipped.push(format!("{trimmed}: expected `family | style | file | …`")), } } (fonts, skipped) } /// A relative path that stays inside the site: no scheme, no leading `/`, no `..` segment, no -/// query or fragment. -fn is_relative_path(p: &str) -> bool { - !p.is_empty() && !p.starts_with('/') && !p.contains(['\\', '?', '#', ':']) && p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..") +/// query or fragment, no ASCII control characters or whitespace, and no percent-encoded control, +/// whitespace or separator characters. +pub fn is_relative_path(p: &str) -> bool { + if p.is_empty() + || p.starts_with('/') + || p.bytes().any(|b| b <= b' ' || b == 0x7f || matches!(b, b'\\' | b'?' | b'#' | b':')) + || !p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..") + { + return false; + } + let b = p.as_bytes(); + let mut decoded = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' { + let Some(hex) = p.get(i + 1..i + 3) else { return false }; + let Ok(byte) = u8::from_str_radix(hex, 16) else { return false }; + // Never allow encoded path syntax (including '%' for a second decode), ASCII + // separators, or controls. High bytes are allowed only as valid UTF-8 below. + if byte <= b' ' || byte == 0x7f || matches!(byte, b'%' | b'/' | b'\\' | b'.' | b'?' | b'#' | b':') { + return false; + } + decoded.push(byte); + i = i.saturating_add(2); + } else { + let Some(&byte) = b.get(i) else { return false }; + decoded.push(byte); + } + i = i.saturating_add(1); + } + std::str::from_utf8(&decoded).is_ok() } /// The scripts a served font can be the fallback for: those the bundled fonts (Inter, which covers @@ -318,8 +353,31 @@ mod tests { assert_eq!(skipped.len(), 9, "{skipped:?}"); } - /// One test for the whole queue: the catalog and the queue are process-wide, and parallel - /// tests taking each other's requests would be flaky. + #[test] + fn rejects_encoded_path_separators_and_malformed_escapes() { + for path in [ + "fonts/%2e%2e/private.ttf", + "fonts%2fprivate.ttf", + "fonts%5cprivate.ttf", + "fonts/%00.ttf", + "fonts/a%7F.ttf", + "fonts/%20.ttf", + "fonts/%zz.ttf", + "fonts/%2.ttf", + "fonts/%C0%AF.ttf", + "fonts/%25%32%65%25%32%65/private.ttf", + "fonts/%C3%28.ttf", + ] { + assert!(!is_relative_path(path), "accepted {path}"); + } + for path in ["fonts/OpenSans[wdth,wght].ttf", "fonts/é.ttf", "fonts/%C3%A9.ttf"] { + assert!(is_relative_path(path), "rejected {path}"); + } + let (fonts, skipped) = parse_manifest("A | Regular | fonts/%2e%2e/a.ttf\nB | Regular | fonts/%zz.ttf\n"); + assert!(fonts.is_empty()); + assert_eq!(skipped.len(), 2); + } + #[test] fn requests_are_limited_to_the_catalog_and_reach_layout() { let served = "Served Test Sans"; diff --git a/crates/text/src/spell.rs b/crates/text/src/spell.rs index 83576c8999..bc2afa63c8 100644 --- a/crates/text/src/spell.rs +++ b/crates/text/src/spell.rs @@ -88,7 +88,7 @@ impl Dictionary { /// Whether `word` is spelled correctly (also accepting `user` words, compared without case). pub fn check(&self, word: &str, user: &HashSet) -> bool { let w = normalize(word); - let w = w.strip_suffix("'s").filter(|b| !b.is_empty()).unwrap_or(&w); + let w = w.strip_suffix("'s").or_else(|| w.strip_suffix("'S")).filter(|b| !b.is_empty()).unwrap_or(&w); let w = w.trim_matches('\''); if w.is_empty() || w.chars().any(|c| c.is_numeric()) || !w.chars().any(char::is_alphabetic) { return true; @@ -258,6 +258,15 @@ mod tests { HashSet::new() } + #[test] + fn all_caps_possessive_is_accepted() { + let d = Dictionary::english(); + for w in ["MOTHER'S", "MONDAY'S", "Mother's", "mother's", "MOTHER’S"] { + assert!(d.check(w, &none()), "{w}"); + } + assert!(!d.check("MOTHER'SX", &none())); + } + #[test] fn bundled_dictionary_loads() { let d = Dictionary::english(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 9ae94e4282..4fe9709942 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -847,6 +847,38 @@ fn variable_font_axes() { assert!(heavy > light * 1.5, "{light} → {heavy}"); } +/// #8797: a style that carries the PostScript name of a variable font's named condensed light +/// instance lays out as wide as that instance's axis values, not at the default instance. Uses +/// Bahnschrift (Windows) or Ubuntu Sans (Ubuntu) when installed, and skips otherwise; the +/// synthesized-font tests in `font_names_tests` cover the instance lookup and its layout width +/// everywhere. +#[test] +fn postscript_name_of_a_variable_instance_lays_out_at_that_instance() { + use photocraft_doc::text::FontVariation; + let candidates = [ + ("C:\\Windows\\Fonts\\bahnschrift.ttf", "Bahnschrift-LightCondensed"), + ("/usr/share/fonts/truetype/ubuntu/UbuntuSans[wdth,wght].ttf", "UbuntuSans-CondensedLight"), + ]; + let Some((bytes, ps)) = candidates.iter().find_map(|(p, ps)| std::fs::read(p).ok().map(|b| (b, *ps))) else { + return; + }; + let mut e = TextEngine::new(); + let Some(fam) = e.fonts.register_font_data(bytes).first().cloned() else { + return; + }; + let width = |e: &mut TextEngine, style: CharStyle| { + let l = e.layout(&styled("Hamburgefonstiv 0123", style), 96.0); + l.lines[0].x1 - l.lines[0].x0 + }; + let base = CharStyle { font_family: fam.clone(), size_pt: 72.0, weight: 300, ..Default::default() }; + let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }]; + let family = width(&mut e, base.clone()); + let instance = width(&mut e, CharStyle { postscript_name: Some(ps.into()), ..base.clone() }); + let explicit = width(&mut e, CharStyle { variations: axes, ..base }); + assert!(explicit < family * 0.9, "the condensed light axes are narrower: {explicit} vs {family}"); + assert!((instance - explicit).abs() < 0.5, "{ps}: {instance} px, its axes: {explicit} px, default instance: {family} px"); +} + #[test] fn warp_bends_rendered_text_and_outlines() { use photocraft_doc::text::TextWarp; @@ -1085,6 +1117,202 @@ fn txt2_keeps_a_kept_objects_extras_and_drops_them_with_its_text() { assert!(object.path(&["0", "keep"]).is_none(), "model extras of another text are dropped"); } +/// The `DocumentObjects` value older Photoshop writes beside the numbered objects +/// (`/DocumentObjects /TextObjects`, named keys, run lengths under `/Length` in UTF-16 units, +/// #6971): one entry per layer, its `\r`-terminated text in `Model /Text`, one paragraph run per +/// paragraph, one style and kern run covering the whole text. +fn photoshop_era_objects(texts: &[&str]) -> crate::engine_data::Value { + use crate::engine_data::Value as E; + let run = |len: i64| E::Dict(vec![("RunData".into(), E::Dict(vec![("0".into(), E::String(String::new()))])), ("Length".into(), E::Int(len))]); + let entries = texts + .iter() + .map(|t| { + let text = format!("{t}\r"); + let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count() as i64).collect(); + let total = text.encode_utf16().count() as i64; + E::Dict(vec![ + ( + "Model".into(), + E::Dict(vec![ + ("Text".into(), E::String(text)), + ("ParagraphRun".into(), E::Dict(vec![("RunArray".into(), E::Array(paragraphs.iter().map(|&n| run(n)).collect()))])), + ("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total)]))])), + ("KernRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total)]))])), + ]), + ), + ("View".into(), E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(0))])]))])), + ]) + }) + .collect(); + E::Dict(vec![("TextObjects".into(), E::Array(entries))]) +} + +/// A whole `Txt2` block shaped like the older Photoshop's: document resources and the objects at +/// `/DocumentObjects /TextObjects`, no numbered `/1 /1` list at all. +fn photoshop_era_txt2(texts: &[&str]) -> Vec { + use crate::engine_data::Value as E; + crate::engine_data::write_bare(&[ + ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), + ("0".into(), E::dict()), + ("DocumentObjects".into(), photoshop_era_objects(texts)), + ]) +} + +/// #6971: Photoshop re-renders a type layer from the objects older Photoshop keeps at +/// `/DocumentObjects /TextObjects` when the file has them, so an edit that left the old text +/// there showed the old text back on reopen. The save refits that copy the way it refits the +/// numbered ones — the new text, one paragraph run per paragraph of it, the other runs to its +/// total — and keeps the entries' view and the rest. +#[test] +fn edited_text_updates_the_photoshop_era_objects() { + use crate::engine_data::Value as E; + use photocraft_doc::text::Kerning; + let prev = photoshop_era_txt2(&["Line 1\rLine 2"]); + let before = crate::psd::parse_txt2(&prev).unwrap(); + let t = styled("Hi\nBye", CharStyle { kerning: Kerning::Optical, ..Default::default() }); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); + assert_eq!(entries.len(), 1, "no entry is added"); + let entry = entries.first().expect("the entry"); + let model = entry.get("Model").expect("the model"); + assert_eq!(model.get("Text").and_then(E::as_str), Some("Hi\rBye\r"), "the stale text is replaced"); + let lens = |key: &str| -> Vec { + model + .path(&[key, "RunArray"]) + .and_then(E::as_array) + .map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64).unwrap_or(-1)).collect()) + .unwrap_or_default() + }; + assert_eq!(lens("ParagraphRun"), vec![3, 4], "one paragraph run per paragraph of the new text"); + assert_eq!(lens("StyleRun"), vec![7], "the style runs cover the new text's UTF-16 units"); + assert_eq!(lens("KernRun"), vec![7]); + assert_eq!( + entry.get("View"), + before.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|e| e.first()).and_then(|e| e.get("View")), + "the view is kept" + ); + // The numbered list is written alongside, as it was before this fix. + let slots = out.path(&["1", "1"]).and_then(E::as_array).expect("the numbered objects"); + assert_eq!(slots.len(), 1); + assert_eq!(slots.first().and_then(|o| o.path(&["0", "0"])).and_then(E::as_str), Some("Hi\rBye\r")); +} + +/// Only the entries of layers the save holds change: another layer's object — one no layer names +/// — is written back as the file has it. +#[test] +fn only_the_edited_photoshop_era_object_changes() { + use crate::engine_data::Value as E; + use photocraft_doc::text::Kerning; + let prev = photoshop_era_txt2(&["One", "Two"]); + let before = crate::psd::parse_txt2(&prev).unwrap(); + let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() }; + let t0 = runs_of("One", &[(3, style(Kerning::Metrics))]); + let t1 = runs_of("Changed", &[(7, style(Kerning::Optical))]); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t0), (1, &t1)], Some(&prev))).unwrap(); + let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); + let was = before.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects before"); + assert_eq!(entries.len(), 2, "no entry is added"); + assert_eq!(entries.first(), was.first(), "the unchanged entry is written back as it was"); + assert_eq!(entries.get(1).and_then(|e| e.path(&["Model", "Text"])).and_then(E::as_str), Some("Changed\r"), "the edited entry takes the new text"); +} + +/// Files saved by the old code have both lists: the numbered object current, the older copy still +/// holding the text of an earlier edit. Re-saving repairs the stale copy even when the numbered +/// object's text matches the layer (nothing else about the file changes). +#[test] +fn legacy_file_with_both_object_lists_repairs_the_stale_one() { + use crate::engine_data::Value as E; + use photocraft_doc::text::Kerning; + let mut prev = photoshop_txt2(&[(4, 1)]); + prev.extend_from_slice(&crate::engine_data::write_bare(&[("DocumentObjects".into(), photoshop_era_objects(&["Old stuff"]))])); + let before = crate::psd::parse_txt2(&prev).unwrap(); + let t = styled("AVA", CharStyle { kerning: Kerning::Metrics, ..Default::default() }); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); + assert_eq!(entries.len(), 1); + let model = entries.first().expect("the entry").get("Model").expect("the model"); + assert_eq!(model.get("Text").and_then(E::as_str), Some("AVA\r"), "the stale copy is repaired"); + let lens = model + .path(&["StyleRun", "RunArray"]) + .and_then(E::as_array) + .map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64).unwrap_or(-1)).collect::>()); + assert_eq!(lens, Some(vec![4]), "its runs cover the layer's text"); + let slots = out.path(&["1", "1"]).and_then(E::as_array).expect("the numbered objects"); + assert_eq!(slots.first().and_then(|o| o.path(&["0", "0"])).and_then(E::as_str), Some("AVA\r"), "the numbered object is kept as it was"); + assert_eq!(before.path(&["1", "1"]).and_then(E::as_array).and_then(|s| s.first()), slots.first(), "the numbered object changes not at all"); +} + +/// When both lists hold the layer's text, the block is written back byte for byte — #2374 holds +/// for the older objects too: re-encoding them would only risk Photoshop refusing the file. +#[test] +fn unchanged_block_with_both_object_lists_is_byte_identical() { + use photocraft_doc::text::Kerning; + let mut prev = photoshop_txt2(&[(4, 1)]); + prev.extend_from_slice(&crate::engine_data::write_bare(&[("DocumentObjects".into(), photoshop_era_objects(&["AVA"]))])); + let t = styled("AVA", CharStyle { kerning: Kerning::Metrics, ..Default::default() }); + assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev); +} + +/// An entry shaped unlike Photoshop writes it — a run without its `/Length`, a `RunArray` that +/// isn't an array, an empty one, an object with no text to replace — is left exactly as the file +/// has it: a half-refitted object (new text over old runs) is what made Photoshop quit (#6968), +/// so the save never writes one. +#[test] +fn odd_photoshop_era_entries_are_left_as_the_file_has_them() { + use crate::engine_data::Value as E; + use photocraft_doc::text::Kerning; + let run = |len: i64| E::Dict(vec![("RunData".into(), E::dict()), ("Length".into(), E::Int(len))]); + let entry = |model: Vec<(&str, E)>| { + E::Dict(vec![("Model".into(), E::Dict(model.into_iter().map(|(k, v)| (k.to_string(), v)).collect())), ("View".into(), E::dict())]) + }; + let with_text = |text: E, style: E| { + entry(vec![ + ("Text", text), + ("ParagraphRun", E::Dict(vec![("RunArray".into(), E::Array(vec![run(4)]))])), + ("StyleRun", E::Dict(vec![("RunArray".into(), style)])), + ]) + }; + let odd = [ + with_text(E::String("Old\r".into()), E::Array(vec![E::Dict(vec![("RunData".into(), E::dict())])])), + with_text(E::String("Old\r".into()), E::Int(3)), + with_text(E::String("Old\r".into()), E::Array(vec![])), + E::Int(3), + entry(vec![("ParagraphRun", E::Dict(vec![("RunArray".into(), E::Array(vec![run(4)]))]))]), + with_text(E::Int(9), E::Array(vec![run(4)])), + ]; + for (i, object) in odd.iter().enumerate() { + let prev = crate::engine_data::write_bare(&[ + ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), + ("0".into(), E::dict()), + ("DocumentObjects".into(), E::Dict(vec![("TextObjects".into(), E::Array(vec![object.clone()]))])), + ]); + let was = crate::psd::parse_txt2(&prev).unwrap(); + let t = styled("New", CharStyle { kerning: Kerning::Optical, ..Default::default() }); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); + assert_eq!( + entries.first(), + was.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|e| e.first()), + "entry {i} is left as the file has it" + ); + } +} + +/// A `TextIndex` past the entries the block holds has no stale copy to fix: nothing is grown to +/// the layer's number (the slot list grows as it always did), and the entries stay the file's. +#[test] +fn a_text_index_beyond_the_photoshop_era_entries_grows_nothing() { + use crate::engine_data::Value as E; + use photocraft_doc::text::Kerning; + let prev = photoshop_era_txt2(&["Only one"]); + let was = crate::psd::parse_txt2(&prev).unwrap(); + let t = styled("Whatever", CharStyle { kerning: Kerning::Optical, ..Default::default() }); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(crate::psd::MAX_TEXT_INDEX, &t)], Some(&prev))).unwrap(); + let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); + assert_eq!(entries.len(), 1, "no entry is grown to the layer's number"); + assert_eq!(entries.first(), was.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|e| e.first())); +} + /// A `Txt2` shaped like Photoshop's: document resources and settings beside the text objects /// (`/0`, `/1 /0`, `/1 /2`), style runs carrying a full style (font, size, leading) besides the /// auto-kern mode, a Latin-1 string without a byte-order mark, reals, and a second object no @@ -1454,48 +1682,126 @@ fn psd_round_trips_antialias_opentype_and_warp() { } } -/// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin. -/// The TySh transform cancels that origin, so importing it as a zero-based layout puts text far -/// off-canvas. Fold the descriptor origin into the imported transform and preserve it on a TySh -/// round trip so every editing and export path uses the same position. -#[test] -fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() { +/// 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. +fn legacy_tysh(source: &TextLayer, transform: Affine, anchor: [f64; 2], engine: &mut TextEngine) -> Vec { use photocraft_psd::descriptor::{Descriptor, Id, Value as D}; - - let source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() }); - let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); - tysh.transform = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] }; - let ink = [8050.0, 8160.0, 8220.0, 8300.0]; - let rect = |class| { + let layout = engine.layout(source, 72.0); + let logical = layout.bounds().unwrap().map(f64::from); + let ink = crate::render::ink_bounds(&layout).unwrap(); + let rect = |class, r: [f64; 4]| { D::Descriptor( Descriptor::new(class) - .with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] }) - .with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] }) - .with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] }) - .with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }), + .with("Left", D::UnitFloat { unit: *b"#Pnt", value: anchor[0] + r[0] }) + .with("Top ", D::UnitFloat { unit: *b"#Pnt", value: anchor[1] + r[1] }) + .with("Rght", D::UnitFloat { unit: *b"#Pnt", value: anchor[0] + r[2] }) + .with("Btom", D::UnitFloat { unit: *b"#Pnt", value: anchor[1] + r[3] }), ) }; + let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(source, 72.0, None)).unwrap(); + tysh.transform = transform; tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox")); - tysh.text.items.push((Id::new("bounds"), rect("bounds"))); - tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox"))); - let data = crate::psd::write_tysh(&tysh); + tysh.text.items.push((Id::new("bounds"), rect("bounds", logical))); + tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox", ink))); + crate::psd::write_tysh(&tysh) +} - let imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap(); - let expected = [4.1667, 0.0, 0.0, 4.1667, 1166.935, 1112.772]; - for (got, want) in imported.transform.m.iter().zip(expected) { - assert!((got - want).abs() < 0.001, "{:?}", imported.transform.m); - } - let (_, rendered) = TextEngine::new().render(&imported, 72.0, PixelFormat::RGBA8); - let placed = rendered.surface.content_bounds(); - assert!(!placed.is_empty(), "the normalized layer renders"); - assert!((1000..3000).contains(&placed.x0) && (500..2000).contains(&placed.y0), "{placed:?}"); +fn assert_transform_near(got: Affine, want: Affine, tolerance: f64, what: &str) { + assert!(got.m.iter().zip(want.m).all(|(g, w)| (g - w).abs() < tolerance), "{what}: {:?} vs {:?}", got.m, want.m); +} - let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, Some(ink.map(|v| v as f32))), 72.0).unwrap(); - for (got, want) in round_trip.transform.m.iter().zip(expected) { - assert!((got - want).abs() < 0.001, "{:?}", round_trip.transform.m); +/// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin. +/// The layout's own ink box is lined up with the saved ink box, so point alignment anchors stay +/// fixed for left, centered and right text through import and a TySh round trip. +#[test] +fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() { + use photocraft_psd::descriptor::Value as D; + + let original = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] }; + // The file's layout origin (the anchor on the first baseline) in its local space. + let anchor = [8050.0, 8160.0]; + let expected = original.mul(&Affine::translate(anchor[0], anchor[1])); + for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] { + let mut source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() }); + source.paragraphs = vec![ParagraphRun { len: source.text.len(), style: ParagraphStyle { align, ..Default::default() } }]; + let mut engine = TextEngine::new(); + let data = legacy_tysh(&source, original, anchor, &mut engine); + + let mut imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap(); + imported.psd_raw = Some(std::sync::Arc::new(data.clone())); + crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None); + assert_eq!(imported.paragraph_runs()[0].style.align, align); + assert_transform_near(imported.transform, expected, 1e-6, &format!("{align:?}: anchor")); + + let (_, rendered) = engine.render(&imported, 72.0, PixelFormat::RGBA8); + let placed = rendered.surface.content_bounds(); + assert!(!placed.is_empty(), "{align:?}: no rendered pixels"); + let mut want = source.clone(); + want.transform = expected; + let (_, expected_pixels) = engine.render(&want, 72.0, PixelFormat::RGBA8); + assert_eq!(placed, expected_pixels.surface.content_bounds(), "{align:?}: raster placement"); + + let native = engine.layout(&imported, 72.0).bounds(); + let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, native), 72.0).unwrap(); + assert_transform_near(round_trip.transform, expected, 0.01, &format!("{align:?}: round trip")); + + // A valid frame may equal the old ink-corner estimate. It must still win over refinement. + let t = crate::psd::parse_tysh(&data).unwrap(); + let Some(D::Descriptor(ink_box)) = t.text.get("boundingBox") else { panic!("boundingBox") }; + let corner = |k| match ink_box.get(k) { + Some(D::UnitFloat { value, .. }) => *value, + _ => panic!("{k}"), + }; + let framed = with_text_index(&data, 0); + let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Name", Some([corner("Left"), corner("Top ")]), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).unwrap(); + let mut with_frame = crate::psd::text_layer_from_tysh(&framed, 72.0).unwrap(); + with_frame.psd_raw = Some(std::sync::Arc::new(framed.clone())); + crate::psd::apply_txt2_frame(&mut with_frame, &framed, &txt2); + let authoritative = with_frame.transform; + crate::psd::refine_legacy_point_origin(&mut with_frame, 72.0, &mut engine, Some(&txt2)); + assert_eq!(with_frame.transform, authoritative, "{align:?}: exact frame wins"); } } +/// #6094 review: `boundingBox` is the tight ink box (a Photoshop-saved Arial 120 pt "..." has +/// `bounds` top -102.98 but `boundingBox` top -11). Text whose ink starts well below the ascent +/// line keeps its baseline: lining the layout box up with the ink box put "..." and "_" about +/// 116 pt too low. +#[test] +fn legacy_origin_lines_up_ink_with_the_ink_bounding_box() { + let original = Affine { m: [1.0, 0.0, 0.0, 1.0, -7900.0, -7550.0] }; + for text in ["Name", "...", "_"] { + let source = styled(text, CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() }); + let mut engine = TextEngine::new(); + let data = legacy_tysh(&source, original, [8000.0, 8000.0], &mut engine); + let mut imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap(); + imported.psd_raw = Some(std::sync::Arc::new(data)); + crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None); + let baseline = imported.transform.apply(photocraft_geom::Point::new(0.0, 0.0)); + assert!((baseline.x - 100.0).abs() < 1e-6 && (baseline.y - 450.0).abs() < 1e-6, "{text:?}: {baseline:?}"); + } +} + +#[test] +fn legacy_origin_uses_the_rendering_engines_registered_face() { + let mut engine = TextEngine::new(); + let mut style = CharStyle { font_family: "JetBrains Mono".into(), font_style: "Regular".into(), size_pt: 100.0, ..Default::default() }; + // This exact-face alias exists only in this engine, like a user-registered font. + engine.fonts.select_named_face(&mut style); + assert!(style.font_family.starts_with(".PhotoCraft-face-")); + let source = with_para(styled("Name", style), ParagraphStyle { align: TextAlign::Right, ..Default::default() }); + let raw = legacy_tysh(&source, Affine::translate(-8000.0, -8000.0), [8100.0, 8200.0], &mut engine); + let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); + // Import preparation has resolved the face before refining placement. + imported.runs = source.runs.clone(); + imported.sync_summary(); + imported.psd_raw = Some(std::sync::Arc::new(raw)); + crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None); + let placed = imported.transform.apply(photocraft_geom::Point::new(0.0, 0.0)); + assert!((placed.x - 100.0).abs() < 1e-6 && (placed.y - 200.0).abs() < 1e-6, "{placed:?}"); +} + /// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary) /// panicked with `expect("EngineDict")` when the layer was written back (PSD export). #[test] @@ -1728,10 +2034,17 @@ fn craft_fonts_cover_japanese_without_system_fonts() { let l = e.layout(&point(text, 24.0), 72.0); assert_eq!(l.glyphs.len(), text.chars().count()); assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef with craft-fonts"); - // Sans (Inter) runs fall back to the Gothic UI family. - assert_eq!(craft_family_of(&l), vec![crate::craft_fonts::UI_JAPANESE_FAMILY]); - // Serif runs fall back to a Mincho face when the build has one (not the web build). - if crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) { + // Sans (Inter) runs fall back to the first CJK craft family in the UI locale's script order: + // the Gothic UI family when Japanese leads that order or the build has no other CJK craft font. + let cjk = |f: &crate::CraftFont| f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant" | "Kore")); + let first = e.fonts.fallback_stack().find_map(|fam| crate::CRAFT_FONTS.iter().find(|f| f.family == fam && cjk(f))).map(|f| f.family); + if !crate::CRAFT_FONTS.iter().any(|f| cjk(f) && !f.is_japanese()) || crate::cjk::ui_script_order()[0] == crate::cjk::CjkScript::Japanese { + assert_eq!(first, Some(crate::craft_fonts::UI_JAPANESE_FAMILY)); + } + assert_eq!(craft_family_of(&l), first.into_iter().collect::>()); + // Serif runs fall back to a Mincho face when the build has one (not the web build) and the + // Japanese craft fonts come first. + if first == Some(crate::craft_fonts::UI_JAPANESE_FAMILY) && crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) { let mut t = point(text, 24.0); t.font_family = "Times New Roman".into(); let l = e.layout(&t, 72.0); @@ -1744,6 +2057,23 @@ fn craft_fonts_cover_japanese_without_system_fonts() { assert!(craft_family_of(&l).is_empty()); } +/// Chinese, Arabic and Thai set in Inter fall back to the craft font for their script when the +/// build has one (Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai in newer craft-fonts). +#[test] +fn craft_fonts_cover_their_other_scripts_without_system_fonts() { + let mut e = TextEngine::new(); + for (script, text) in [("Hans", "中文图层"), ("Arab", "مرحبا"), ("Thai", "ภาษาไทย")] { + if !crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&script)) { + eprintln!("skipping {script}: built without a craft font for it (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); + continue; + } + let l = e.layout(&point(text, 24.0), 72.0); + assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{script}: no .notdef with craft-fonts"); + let fams = craft_family_of(&l); + assert!(fams.iter().any(|fam| crate::CRAFT_FONTS.iter().any(|f| f.family == *fam && f.scripts.contains(&script))), "{script}: drawn with {fams:?}"); + } +} + #[test] fn works_without_craft_fonts() { // Whatever the build: the bundled fonts load, Latin lays out, and Japanese never panics @@ -2446,3 +2776,338 @@ fn a_word_wider_than_the_paragraph_box_breaks_at_the_box_edge() { assert_eq!(l.lines.len(), 2, "{:?}", l.lines); assert_eq!(l.lines[1].range.start, 3, "{:?}", l.lines); } + +/// 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)]) +} + +/// An ellipse from four kappa curves inside `[x, y, w, h]`. +fn area_ellipse(x: f64, y: f64, w: f64, h: f64) -> photocraft_doc::vector::Path { + use photocraft_doc::vector::{Knot, Subpath}; + use photocraft_geom::Point; + let (rx, ry, cx, cy) = (w / 2.0, h / 2.0, x + w / 2.0, y + h / 2.0); + let (kx, ky) = (0.552_284_75 * rx, 0.552_284_75 * ry); + let p = Point::new; + let knots = vec![ + Knot::smooth(p(cx, y), p(cx - kx, y), p(cx + kx, y)), + Knot::smooth(p(x + w, cy), p(x + w, cy - ky), p(x + w, cy + ky)), + Knot::smooth(p(cx, y + h), p(cx + kx, y + h), p(cx - kx, y + h)), + Knot::smooth(p(x, cy), p(x, cy + ky), p(x, cy - ky)), + ]; + photocraft_doc::vector::Path::new(vec![Subpath { closed: true, knots, op: Default::default() }]) +} + +const BUBBLE_TEXT: &str = "Well, that went better than expected. Now where did the cat hide the keys this time?"; + +/// Every laid-out line sits inside a span of the shape across its band. +fn assert_lines_inside(l: &crate::TextLayout, path: &photocraft_doc::vector::Path) { + let a = crate::area::AreaShape::new(path, false).unwrap(); + for line in &l.lines { + let spans = a.spans(line.baseline - line.ascent, line.baseline + line.descent, 0.0); + assert!(spans.iter().any(|&(s0, s1)| line.x0 >= s0 - 0.5 && line.x1 <= s1 + 0.5), "{line:?} not inside {spans:?}"); + } +} + +#[test] +fn area_text_stays_inside_an_ellipse() { + let mut e = TextEngine::new(); + let mut t = point(BUBBLE_TEXT, 18.0); + let path = area_ellipse(0.0, 0.0, 260.0, 160.0); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 4, "{}", l.lines.len()); + assert_lines_inside(&l, &path); + // The top of an ellipse is too narrow for a line at its very edge: the first line starts lower. + assert!(l.lines[0].baseline - l.lines[0].ascent > 1.0, "{:?}", l.lines[0]); + // Lines go down the shape in text order. + for w in l.lines.windows(2) { + assert!(w[1].baseline >= w[0].baseline && w[1].range.start >= w[0].range.end, "{w:?}"); + } + // Middle lines are wider than the first (the ellipse is widest in the middle). + let width = |i: usize| l.lines[i].x1 - l.lines[i].x0; + assert!(width(l.lines.len() / 2) > width(0), "{l:?}"); +} + +#[test] +fn area_overflow_is_hidden() { + let mut e = TextEngine::new(); + let mut t = point(&BUBBLE_TEXT.repeat(10), 14.0); + let path = area_ellipse(0.0, 0.0, 200.0, 80.0); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert!(!l.lines.is_empty()); + assert!(l.lines.last().unwrap().range.end < t.text.len(), "the rest is hidden"); + assert_lines_inside(&l, &path); +} + +/// A long word that reaches the narrow bottom of a full bubble is hidden with the rest, not +/// drawn past the outline: the wide middle rows would hold it, so the shape just ran out. +#[test] +fn area_overflow_at_the_narrow_bottom_is_hidden() { + let mut e = TextEngine::new(); + let words = "Unfortunately the misunderstanding between representatives continued indefinitely. "; + for (w, h, size) in [(260.0, 160.0, 12.0), (180.0, 140.0, 14.0), (150.0, 220.0, 12.0)] { + let mut t = point(&words.repeat(8), size); + let path = area_ellipse(0.0, 0.0, w, h); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert!(!l.lines.is_empty()); + assert!(l.lines.last().unwrap().range.end < t.text.len(), "the rest is hidden"); + assert_lines_inside(&l, &path); + } +} + +/// The same in a later paragraph: its own rows start below the wide top of the shape, but the +/// shape would still hold the word there, so it is hidden rather than drawn past the outline. +#[test] +fn area_overflow_in_a_later_paragraph_is_hidden() { + let mut e = TextEngine::new(); + // A funnel: 300 px wide at the top, a point at the bottom. + let path = area_poly(&[(0.0, 0.0), (300.0, 0.0), (150.0, 300.0)]); + for n in (10..120).step_by(5) { + let text = format!("{}\nmisunderstanding and more", "cat ".repeat(n)); + let mut t = point(&text, 12.0); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert_lines_inside(&l, &path); + } +} + +#[test] +fn area_lines_center_in_their_span() { + let mut e = TextEngine::new(); + let mut t = point(BUBBLE_TEXT, 12.0); + t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: ParagraphStyle { align: TextAlign::Center, ..Default::default() } }]; + // A diamond: every row's span is centred on x = 100. + t.shape = TextShape::Area { path: area_poly(&[(100.0, 0.0), (200.0, 100.0), (100.0, 200.0), (0.0, 100.0)]) }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 3); + for line in &l.lines { + assert!(((line.x0 + line.x1) / 2.0 - 100.0).abs() < 1.5, "{line:?}"); + } +} + +/// Horizontal scale applies after shaping, so area lines break at the span width divided by it: +/// widened text stays inside the shape and centred lines stay centred (#5565). +#[test] +fn horizontally_scaled_area_text_stays_inside_its_spans() { + let mut e = TextEngine::new(); + let style = CharStyle { font_family: "Inter".into(), size_pt: 12.0, horizontal_scale: 1.5, ..Default::default() }; + let mut t = styled(BUBBLE_TEXT, style); + let path = area_ellipse(0.0, 0.0, 240.0, 200.0); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 3, "{:?}", l.lines); + assert_lines_inside(&l, &path); + t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: ParagraphStyle { align: TextAlign::Center, ..Default::default() } }]; + t.shape = TextShape::Area { path: area_poly(&[(100.0, 0.0), (200.0, 100.0), (100.0, 200.0), (0.0, 100.0)]) }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 2, "{:?}", l.lines); + for line in &l.lines { + assert!(((line.x0 + line.x1) / 2.0 - 100.0).abs() < 1.5, "{line:?}"); + } +} + +#[test] +fn area_rows_split_around_a_hole() { + use photocraft_doc::vector::{PathOp, Subpath}; + let mut e = TextEngine::new(); + let mut t = point(&BUBBLE_TEXT.repeat(6), 12.0); + let path = photocraft_doc::vector::Path::new(vec![ + Subpath::polygon(&[(0.0, 0.0), (300.0, 0.0), (300.0, 300.0), (0.0, 300.0)]), + Subpath::polygon(&[(120.0, 100.0), (180.0, 100.0), (180.0, 200.0), (120.0, 200.0)]).with_op(PathOp::Subtract), + ]); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert_lines_inside(&l, &path); + // Some baseline carries two lines: one left of the hole, one right of it, left first. + let w = l.lines.windows(2).find(|w| (w[0].baseline - w[1].baseline).abs() < 1e-3).expect("a row split around the hole"); + let (left, right) = (&w[0], &w[1]); + assert!(left.x1 <= 120.5 && right.x0 >= 179.5, "{left:?} {right:?}"); +} + +#[test] +fn vertical_area_text_stays_inside() { + use photocraft_doc::text::Orientation; + let mut e = TextEngine::new(); + let mut t = point(BUBBLE_TEXT, 12.0); + t.orientation = Orientation::Vertical; + t.shape = TextShape::Area { path: area_poly(&[(0.0, 0.0), (120.0, 0.0), (120.0, 200.0), (0.0, 200.0)]) }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 2, "several columns"); + for g in &l.glyphs { + assert!(g.x > -1.0 && g.x < 121.0 && g.y > -1.0 && g.y < 201.0, "{g:?}"); + } +} + +#[test] +fn area_without_room_lays_out_nothing_and_a_bad_path_lays_out_as_point_text() { + let mut e = TextEngine::new(); + let mut t = point("Hello", 40.0); + // 10 px tall: no 40 pt line fits. + t.shape = TextShape::Area { path: area_poly(&[(0.0, 0.0), (300.0, 0.0), (300.0, 10.0), (0.0, 10.0)]) }; + assert!(e.layout(&t, 72.0).lines.is_empty()); + // An open path encloses nothing (only reachable from a hostile file): point text, no panic. + t.shape = TextShape::Area { path: photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(0.0, 0.0), (9.0, 9.0)])]) }; + let point_layout = e.layout(&point("Hello", 40.0), 72.0); + assert_eq!(e.layout(&t, 72.0).lines.len(), point_layout.lines.len()); +} + +#[test] +fn area_word_wider_than_every_span_overflows_instead_of_hiding_the_text() { + use photocraft_doc::vector::Subpath; + let mut e = TextEngine::new(); + // Two 40 px columns 120 px apart: their bounding box is 200 px wide, but no span is wider + // than 40 px, so no row can ever hold the long word. + let mut t = point("Wonderfulness is a long word", 12.0); + let path = photocraft_doc::vector::Path::new(vec![ + Subpath::polygon(&[(0.0, 0.0), (40.0, 0.0), (40.0, 200.0), (0.0, 200.0)]), + Subpath::polygon(&[(160.0, 0.0), (200.0, 0.0), (200.0, 200.0), (160.0, 200.0)]), + ]); + t.shape = TextShape::Area { path }; + let l = e.layout(&t, 72.0); + let first = l.lines.first().expect("the text is shown"); + // The word overflows its 40 px span on the first row (box text would break it, #6694). + assert!(first.x1 - first.x0 > 40.0 && first.x1 - first.x0 < 200.0, "{first:?}"); + assert!(first.x0 >= -0.5, "{first:?}"); + // Nothing is hidden. + assert_eq!(l.lines.last().map(|line| line.range.end), Some(t.text.len()), "{:?}", l.lines); +} + +#[test] +fn single_size_area_paragraph_breaks_in_one_pass() { + let mut e = TextEngine::new(); + let mut t = point(BUBBLE_TEXT, 12.0); + t.shape = TextShape::Area { path: area_poly(&[(0.0, 0.0), (300.0, 0.0), (300.0, 300.0), (0.0, 300.0)]) }; + crate::layout::AREA_PASSES.with(|c| c.set(0)); + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 2); + // Rows are planned with the paragraph's real metrics: no re-plan and no skipped spans. + assert_eq!(crate::layout::AREA_PASSES.with(|c| c.get()), 1); +} + +#[test] +fn area_word_skips_a_narrow_span_for_a_wider_one_on_the_same_row() { + let mut e = TextEngine::new(); + let mut t = point("Wonderful things happen in speech bubbles every single day.", 12.0); + // A U: a 24 px left arm and a wide right arm above y = 150, joined below it. + let path = area_poly(&[(0.0, 0.0), (24.0, 0.0), (24.0, 150.0), (80.0, 150.0), (80.0, 0.0), (300.0, 0.0), (300.0, 200.0), (0.0, 200.0)]); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert_lines_inside(&l, &path); + let first = l.lines.first().expect("the text is shown"); + // "Wonderful" is wider than the left arm: it starts the text in the right arm, on the first + // row, and nothing is put in the left arm's span on that row. + assert_eq!(first.range.start, 0); + assert!(first.x0 >= 79.5, "{first:?}"); + assert!(first.baseline < 30.0, "first row: {first:?}"); + assert_eq!(l.lines.iter().filter(|line| (line.baseline - first.baseline).abs() < 1e-3).count(), 1, "{:?}", l.lines); + assert_eq!(l.lines.last().map(|line| line.range.end), Some(t.text.len()), "nothing hidden"); +} + +#[test] +fn area_stacks_paragraphs_with_leading_and_space_before() { + let mut e = TextEngine::new(); + let text = "First paragraph of the bubble text.\nSecond paragraph goes right after it."; + let mut t = point(text, 12.0); + let style = ParagraphStyle { space_before_pt: 6.0, ..Default::default() }; + let leading = style.auto_leading * 12.0; + t.paragraphs = vec![ParagraphRun { len: text.len(), style }]; + let path = area_poly(&[(0.0, 0.0), (120.0, 0.0), (120.0, 300.0), (0.0, 300.0)]); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert_lines_inside(&l, &path); + let (p0, p1): (Vec<_>, Vec<_>) = l.lines.iter().partition(|line| line.paragraph == 0); + assert!(p0.len() >= 2 && p1.len() >= 2, "both paragraphs wrap: {:?}", l.lines); + assert!(p1.iter().all(|line| line.paragraph == 1)); + // The second paragraph starts right after the break, one leading plus space before lower. + let second = text.find('\n').unwrap() + 1; + assert_eq!(p1[0].range.start, second); + let gap = p1[0].baseline - p0.last().unwrap().baseline; + assert!((gap - (leading + 6.0)).abs() < 1.0, "gap {gap}, want {}", leading + 6.0); + // Lines within a paragraph are one leading apart. + for w in p0.windows(2) { + assert!((w[1].baseline - w[0].baseline - leading).abs() < 1.0, "{w:?}"); + } + assert_eq!(l.lines.last().map(|line| line.range.end), Some(text.len())); +} + +#[test] +fn area_justify_fills_each_span_but_the_last_line() { + let mut e = TextEngine::new(); + let mut t = point(&BUBBLE_TEXT.repeat(2), 12.0); + t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: ParagraphStyle { align: TextAlign::JustifyLeft, ..Default::default() } }]; + let path = area_ellipse(0.0, 0.0, 260.0, 160.0); + t.shape = TextShape::Area { path: path.clone() }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 4, "{}", l.lines.len()); + assert_lines_inside(&l, &path); + let a = crate::area::AreaShape::new(&path, false).unwrap(); + let span_of = |line: &crate::LineInfo| { + a.spans(line.baseline - line.ascent, line.baseline + line.descent, 0.0) + .into_iter() + .find(|&(s0, s1)| line.x0 >= s0 - 0.5 && line.x1 <= s1 + 0.5) + .unwrap() + }; + let (last, rest) = l.lines.split_last().unwrap(); + for line in rest { + // Justified: the glyphs run from one edge of the line's span to the other. + let (s0, s1) = span_of(line); + let ink = l.glyphs.iter().filter(|g| (g.y - line.baseline).abs() < 1e-3).map(|g| g.x).fold(f32::MIN, f32::max); + assert!((line.x0 - s0).abs() < 1.0, "{line:?} in {s0}..{s1}"); + assert!(ink > s1 - 12.0, "last glyph at {ink}, span ends {s1}: {line:?}"); + } + // The last line is set flush left, not stretched. + let (s0, s1) = span_of(last); + assert!((last.x0 - s0).abs() < 1.0 && last.x1 < s1 - 12.0, "{last:?} in {s0}..{s1}"); +} + +/// #8602: each colour is rasterised only over its own glyphs instead of the whole text, and the +/// pixels match a full-size pass per colour (plain, faux bold, underlined, rotated and warped +/// text; RGB and CMYK), up to float rounding in the coverage sums. +#[test] +fn per_colour_passes_match_full_size_passes() { + use photocraft_doc::text::TextWarp; + let mut e = TextEngine::new(); + let text = "Many coloured runs of text, one colour a letter, across two lines\nof a point text layer"; + let colours = [Color::rgb(0.9, 0.1, 0.1), Color::rgb(0.1, 0.7, 0.2), Color::rgb(0.1, 0.2, 0.9), Color::rgb(0.6, 0.6, 0.1)]; + let runs = |bold: bool| { + text.char_indices() + .map(|(i, ch)| { + let colour = colours[i % colours.len()]; + let style = CharStyle { size_pt: 28.0, color: colour, faux_bold: bold && i % 3 == 0, underline: i % 5 == 0, ..Default::default() }; + TextRun { len: ch.len_utf8(), style } + }) + .collect::>() + }; + let cases = [ + TextLayer { text: text.into(), runs: runs(false), transform: Affine::translate(10.0, 40.0), ..Default::default() }, + TextLayer { text: text.into(), runs: runs(true), transform: Affine::rotate(0.3).then(&Affine::translate(30.0, 90.0)), ..Default::default() }, + TextLayer { + text: text.into(), + runs: runs(true), + transform: Affine::translate(10.0, 60.0), + warp: Some(TextWarp { style: "warpArc".into(), value: 40.0, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }), + ..Default::default() + }, + ]; + for (k, t) in cases.iter().enumerate() { + let l = e.layout(t, 72.0); + let warp = crate::render::layout_deform(&l, t); + for format in [PixelFormat::RGBA8, PixelFormat::CMYKA8] { + let fast = crate::render::rasterize_passes(&l, &t.transform, format, t.antialias, warp.as_ref(), true); + let full = crate::render::rasterize_passes(&l, &t.transform, format, t.antialias, warp.as_ref(), false); + assert_eq!(fast.rect, full.rect, "case {k}"); + let (a, b) = (fast.surface.read_region(fast.rect), full.surface.read_region(full.rect)); + assert!(!a.is_empty()); + // Compare what shows: colour weighted by alpha (a fully transparent pixel's colour is moot). + let ch = format.channels(); + let shown = |px: &[f32]| { + px.chunks_exact(ch).flat_map(|p| p[..ch - 1].iter().map(|c| c * p[ch - 1]).chain([p[ch - 1]]).collect::>()).collect::>() + }; + let worst = shown(&a).iter().zip(&shown(&b)).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(worst <= 1.0 / 255.0, "case {k} {format:?}: largest difference {worst}"); + } + } +} diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index f148a18e1b..439fc453bf 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -110,6 +110,13 @@ impl Warp { } } +/// The arc length along `path`, measured as type on it measures it (from the end it starts at +/// with `flip`), of the point on the path nearest (`x`, `y`): the `start` that puts the type's +/// origin where the Type tool clicked on the path. `None` for a path without length. +pub fn arc_length_at(path: &Subpath, flip: bool, x: f64, y: f64) -> Option { + PathFrame::new(path, 0.0, flip).map(|f| f.project(x, y)) +} + /// Most cubic segments of a type path that are followed (a hostile file cannot make this large). const MAX_PATH_SEGMENTS: usize = 4096; @@ -163,6 +170,22 @@ impl PathFrame { self.cum.last().copied().unwrap_or(0.0) } + /// Arc length of the point on the flattened path nearest (`x`, `y`). + fn project(&self, x: f64, y: f64) -> f64 { + let mut best = (f64::INFINITY, 0.0); + for (i, w) in self.pts.windows(2).enumerate() { + let [a, b] = [w[0], w[1]]; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((x - a.0) * dx + (y - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + let d = (a.0 + dx * t - x).hypot(a.1 + dy * t - y); + if d < best.0 { + best = (d, self.cum.get(i).copied().unwrap_or(0.0) + len2.sqrt() * t); + } + } + best.1 + } + /// Point and unit tangent at arc length `s`; past either end the end tangent continues straight. fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) { let last = self.pts.len() - 1; @@ -211,6 +234,18 @@ mod tests { } } + /// The Type tool's click on a path: the nearest point's arc length, from the other end with flip. + #[test] + fn arc_length_at_projects_onto_the_path() { + let line = Subpath::polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)]); + let at = |flip, x, y| arc_length_at(&line, flip, x, y).unwrap(); + assert!((at(false, 30.0, -8.0) - 30.0).abs() < 1e-6); + assert!((at(false, 110.0, 20.0) - 120.0).abs() < 1e-6, "on the second segment"); + assert!((at(true, 30.0, -8.0) - 120.0).abs() < 1e-6, "flipped: measured from the far end"); + assert!(at(false, -50.0, 0.0).abs() < 1e-6, "clamped to the start"); + assert!(arc_length_at(&Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), false, 0.0, 0.0).is_none()); + } + #[test] fn styles_resolve_and_none_is_identity() { assert_eq!(psd_style("Arc"), Some("warpArc")); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index fcdea7bd0f..c95da851e0 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -30,18 +30,22 @@ photocraft-format = { workspace = true } # Decoding the brand mark's PNG (`brand.rs`); already linked through photocraft-io. photocraft-codecs = { workspace = true } egui = { workspace = true } -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { workspace = true, features = ["wgpu", "accesskit"] } +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } # Display order of right-to-left labels (`bidi_text.rs`); already linked through usvg. unicode-bidi = "0.3" -base64 = "0.22" +base64 = "0.23" # Inflating the translation catalogs, stored deflated (build.rs); already linked through photocraft-io. flate2 = { workspace = true } +web-time = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Floating panels open in their own OS windows on the desktop; the web keeps them in the page. +craft-ui = { workspace = true, features = ["native-windows"] } +craft-update = { workspace = true } rayon = { workspace = true } # Safe native UI-language APIs (Windows/macOS); no subprocess or web dependency. sys-locale = "=0.3.2" @@ -54,7 +58,7 @@ winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", [dev-dependencies] # 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"] } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } # Synthetic raw files for the open-time Camera Raw dialog tests. photocraft-raw = { workspace = true, features = ["testgen"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] } @@ -63,11 +67,13 @@ photocraft-testkit = { workspace = true } # examples/perf_scenarios.rs (a 500-brush .abr for the #210 import row) and large_file_bench.rs # (the generated 2 GB PSD and 4.4 GB PSB of P20, `fixtures`). photocraft-psd = { workspace = true, features = ["testgen"] } +# Isolated, self-cleaning folders for filesystem tests (the LUT folder, #6558). +tempfile = "3" [build-dependencies] # Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them. flate2 = { workspace = true } # egui's default fonts, deflated by build.rs for egui_fonts.rs (the same version egui uses). -epaint_default_fonts = "0.36" +epaint_default_fonts = { version = "0.37", features = ["monochrome_emoji_fonts"] } serde_json = { workspace = true } toml = "1" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 900370eb69..df65585d7e 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -37,8 +37,11 @@ fn main() { fn deflate_egui_fonts() { use std::io::Write as _; let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("egui_fonts"); + // `egui-icons` is unconditional in 0.37 (a handful of private-use symbols, 3.5 kB); the two + // emoji fonts come through `monochrome_emoji_fonts` in Cargo.toml. let fonts = [ ("Hack-Regular", epaint_default_fonts::HACK_REGULAR), + ("egui-icons", epaint_default_fonts::EGUI_ICONS), ("NotoEmoji-Regular", epaint_default_fonts::NOTO_EMOJI_REGULAR), ("Ubuntu-Light", epaint_default_fonts::UBUNTU_LIGHT), ("emoji-icon-font", epaint_default_fonts::EMOJI_ICON), diff --git a/crates/ui-egui/examples/color_picker_demo.rs b/crates/ui-egui/examples/color_picker_demo.rs index 45daa45c12..f7c304a007 100644 --- a/crates/ui-egui/examples/color_picker_demo.rs +++ b/crates/ui-egui/examples/color_picker_demo.rs @@ -64,7 +64,7 @@ fn main() { if screen_picker::showing(ui.ctx()) { screen_picker::show(ui.ctx()); } else { - screen_picker::button(ui, egui::Id::new("demo")); + screen_picker::button(ui, egui::Id::unique("demo")); } }, PhotocraftApp::new(photocraft_engine::Session::new(), services), diff --git a/crates/ui-egui/examples/layout_bench.rs b/crates/ui-egui/examples/layout_bench.rs index a846415423..e57035c137 100644 --- a/crates/ui-egui/examples/layout_bench.rs +++ b/crates/ui-egui/examples/layout_bench.rs @@ -144,6 +144,25 @@ fn point_on(h: &H, id: LayerId) -> Option<[f64; 2]> { None } +/// Preserve current Smart Objects and restore only legacy pixel fallbacks in the fixture. +fn restore_layout_smart_objects(s: &mut photocraft_engine::Session) -> Result<(), photocraft_engine::EngineError> { + let st = s.active().ok_or(photocraft_engine::EngineError::NoDocument)?; + let ids: Vec<_> = st + .doc + .walk() + .iter() + .filter(|(_, _, l)| { + (l.name == "Logo" || (l.name.starts_with("Product ") && !l.name.contains('—') && !l.is_group())) && !matches!(&l.content, LayerContent::Smart(_)) + }) + .map(|(_, _, l)| l.id.0) + .collect(); + for id in ids { + s.execute("layer.select", json!({"layer": id}))?; + s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": id}))?; + } + Ok(()) +} + fn main() { reexec_with_gpu_sync(); let args: Vec = std::env::args().collect(); @@ -165,14 +184,13 @@ fn main() { let t = Instant::now(); let r = photocraft_io::import("layout.psd", &psd).expect("psd import"); println!("PSD: {:.1} MB, save {t_save:.0} ms, open {:.0} ms ({} warnings)", psd.len() as f64 / 1e6, ms(t), r.warnings.len()); - // PSD export writes engine-made smart objects as pixels (no SoLd yet): convert them back, - // as a Photoshop-made file would have them. + // Current PSDs preserve Smart Objects. Restore only raster fallbacks from older + // writers, selecting each target before the command checks its enabled state. let mut s = photocraft_engine::Session::new(); s.open_document(r.document, None); - let smart = |l: &photocraft_doc::Layer| l.name == "Logo" || (l.name.starts_with("Product ") && !l.name.contains('—') && !l.is_group()); - let ids: Vec = s.active().map(|st| st.doc.walk().iter().filter(|(_, _, l)| smart(l)).map(|(_, _, l)| l.id.0).collect()).unwrap_or_default(); - for id in ids { - s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": id})).expect("convert"); + if let Err(error) = restore_layout_smart_objects(&mut s) { + eprintln!("cannot prepare layout benchmark: {error}"); + std::process::exit(1); } (*s.active().expect("doc").doc).clone() }; @@ -470,3 +488,38 @@ fn reexec_with_gpu_sync() { let status = std::process::Command::new(exe).args(std::env::args().skip(1)).env("PHOTOCRAFT_GPU_SYNC", "1").status().expect("re-exec"); std::process::exit(status.code().unwrap_or(1)); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restore_keeps_native_smart_objects_and_converts_legacy_pixels_once() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 4, "height": 3, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Logo"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#204060"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let native = s.active().unwrap().active_layer.unwrap(); + let before = s.active().unwrap().doc.layer(native).unwrap().content.clone(); + s.execute("layer.new.layer", json!({"name": "Product 1"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#406020"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Tile 1"})).unwrap(); + let tile = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.select", json!({"layer": native.0})).unwrap(); + let history = s.active().unwrap().history.past_len(); + restore_layout_smart_objects(&mut s).expect("mixed current and legacy fixture prepares without re-converting a Smart Object"); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(native).unwrap().content, before); + assert!(matches!(&st.doc.layer(tile).unwrap().content, LayerContent::Raster(_))); + assert!(st.doc.walk().iter().any(|(_, _, l)| l.name == "Product 1" && matches!(&l.content, LayerContent::Smart(_)))); + assert_eq!(st.history.past_len(), history + 1); + restore_layout_smart_objects(&mut s).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), history + 1, "preparation is idempotent"); + } + + #[test] + fn restore_without_a_document_returns_an_error() { + assert!(matches!(restore_layout_smart_objects(&mut photocraft_engine::Session::new()), Err(photocraft_engine::EngineError::NoDocument))); + } +} diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index c6ca4cd605..7fa07a10bc 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -9,7 +9,9 @@ //! `--system-theme light|dark|none` supplies a deterministic OS appearance for theme captures. //! //! `--safe-gpu` draws the canvas on the CPU path, like the app's `--safe-gpu` launch. +//! `--hardware-filtering` respects texture samplers; the default predictable renderer forces bilinear filtering. //! `--wayland-notice` previews the native file drag-and-drop guidance shown in Wayland sessions. +//! `--update-notice` previews a newer release without contacting the network. //! `--custom-titlebar` draws the Windows/Linux title bar (caption buttons in the top bar). //! //! `--monitor 1366x768 --window-top 31` simulates the display the window is on (in points) and @@ -37,18 +39,20 @@ fn main() { .unwrap_or((1440.0, 900.0)); let scale: f32 = arg(&args, "--scale").and_then(|s| s.parse().ok()).unwrap_or(2.0); let wayland_notice = args.iter().any(|a| a == "--wayland-notice"); + let update_notice = args.iter().any(|a| a == "--update-notice"); let script: Vec<(String, Value)> = arg(&args, "--script").map(|s| serde_json::from_str::>(&s).expect("--script must be [[method, params], …]")).unwrap_or_default(); let services = Services { - import: Some(Box::new(|name: &str, bytes: &[u8], depth: usize| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), export: Some(Box::new(|doc: &photocraft_doc::Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| { let mut opts = photocraft_io::ExportOptions::default(); if let Some(q) = settings.jpeg_quality { opts.encode.jpeg_quality = q; } + opts.encode.jpeg_format = settings.jpeg_format; opts.encode.webp_lossless = settings.webp_lossless; if let Some(q) = settings.webp_quality { opts.encode.webp_quality = q; @@ -67,13 +71,28 @@ fn main() { let custom_titlebar = args.iter().any(|a| a == "--custom-titlebar"); // `--settle-ms N`: keep rendering frames for N ms before the capture (e.g. mid-job). let settle_ms: u64 = arg(&args, "--settle-ms").and_then(|s| s.parse().ok()).unwrap_or(0); - let mut harness = - egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(scale).with_max_steps(64).wgpu().build_eframe(move |cc| { + let mut render_options = eframe::egui_wgpu::RendererOptions::PREDICTABLE; + render_options.predictable_texture_filtering = !args.iter().any(|a| a == "--hardware-filtering"); + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(w, h)) + .with_pixels_per_point(scale) + .with_max_steps(64) + .with_render_options(render_options) + .wgpu() + .build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut services = services; services.is_wayland = wayland_notice; services.xwayland_command = wayland_notice.then(|| "WAYLAND_DISPLAY= photocraft".into()); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + if update_notice { + app.session.updates.status = photocraft_engine::updates_cmds::Status::Available { + release: photocraft_engine::updates_cmds::Release { + version: "99.0.0".into(), + url: "https://github.com/storytold/photocraft/releases/tag/v99.0.0".into(), + }, + }; + } app.background_jobs = background_jobs; app.custom_titlebar = custom_titlebar; // `--safe-gpu`: the CPU canvas, as the desktop app's `--safe-gpu` launch. diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index a9fbc56add..2c5e423314 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -95,8 +95,32 @@ pub(crate) fn assign_shortcut(app: &mut PhotocraftApp, name: &str, shortcut: &st /// Replay edits synchronously, with view steps handled by the shell in the same order. /// Engine steps keep the headless action semantics (no dialogs or background jobs). pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result { - let (action, from) = actions_cmds::playback_plan(&app.session, params).map_err(|e| e.to_string())?; - let recording = actions_cmds::begin_playback(&mut app.session, &action).map_err(|e| e.to_string())?; + play_owned(app, params, None) +} + +/// `suspended` is the recording a paused part of the same call took ([`PausedPlayback::record`]); it comes +/// back, with that named call journaled, only when the whole call ends. +fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) -> Result { + let (action, from) = match actions_cmds::playback_plan(&app.session, params) { + Ok(plan) => plan, + Err(e) => { + restore_recording(app, suspended.as_ref()); + return Err(e.to_string()); + } + }; + let recording = match actions_cmds::begin_playback(&mut app.session, &action) { + Ok(r) => r, + Err(e) => { + restore_recording(app, suspended.as_ref()); + return Err(e.to_string()); + } + }; + let owner = recording + .map(|(_, idx)| { + let into = app.session.actions.list.get(idx).map(|a| a.name.clone()); + json!({"index": idx, "into": into, "action": action.name, "from": from}) + }) + .or(suspended); let mut ran = 0u64; let mut failed = None; let mut paused = None; @@ -105,41 +129,46 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result { if let Some(error) = actions_cmds::nested_failure(id, &value).or_else(|| actions_cmds::save_target_closed(&app.session, id, target)) { @@ -159,7 +188,19 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result { + 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), + // No continuation to carry the recording: the call ends here. + (owner, _) => restore_recording(app, owner.as_ref()), + } + } + _ => restore_recording(app, owner.as_ref()), + } Ok(match (failed, paused) { (Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}), (None, Some(p)) => json!({"action": action.name, "ran": ran, "paused": p}), @@ -175,21 +216,28 @@ pub(crate) fn run_view(app: &mut PhotocraftApp, id: &str, params: Value) -> Resu let v = app.ui.views.get_mut(i).ok_or("no document view")?; v.fill_pending = false; match id { - "view.fitOnScreen" => v.fit_pending = true, + "view.fitOnScreen" => { + v.fit_pending = true; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); + } "view.zoomIn" => { v.fit_pending = false; v.zoom = crate::zoom_levels::step(v.zoom, 1, v.doc_size); + v.request_zoom_animation(app.session.prefs().general.animated_zoom); } "view.zoomOut" => { v.fit_pending = false; v.zoom = crate::zoom_levels::step(v.zoom, -1, v.doc_size); + v.request_zoom_animation(app.session.prefs().general.animated_zoom); } "view.actualPixels" => { v.fit_pending = false; v.zoom = 1.0; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); } _ => return Err(format!("unsupported view command: {id}")), } + v.zoom_anchor = None; app.session.journal.push((id.into(), params)); Ok(Value::Null) } @@ -406,9 +454,9 @@ fn insert_stop_dialog(app: &mut PhotocraftApp) -> Result { } pub fn insert_stop_body(ui: &mut egui::Ui, fields: &mut serde_json::Map) { - ui.label(tl!("Message:")); + let message_label = ui.label(tl!("Message:")); let mut message = fields.get("message").and_then(Value::as_str).unwrap_or_default().to_string(); - ui.add(egui::TextEdit::multiline(&mut message).desired_rows(4).desired_width(320.0)); + ui.add(egui::TextEdit::multiline(&mut message).desired_rows(4).desired_width(320.0)).labelled_by(message_label.id); fields.insert("message".into(), json!(message)); ui.add_space(6.0); let mut allow = fields.get("allowContinue").and_then(Value::as_bool).unwrap_or(false); @@ -436,24 +484,119 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) { + let Some(owner) = owner else { return }; + let (Some(idx), Some(into), Some(name)) = ( + owner.get("index").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()), + owner.get("into").and_then(Value::as_str), + owner.get("action").and_then(Value::as_str), + ) else { + return; + }; + let from = owner.get("from").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0); + // `suspended_guard` refuses what would move the recorded action, but another command may + // still replace the list: the call goes to the action of that name, never to whatever now + // sits at the old index, and is dropped if that action is gone. + let list = &app.session.actions.list; + let target = if list.get(idx).is_some_and(|a| a.name == into) { Some(idx) } else { list.iter().position(|a| a.name == into) }; + if let Some(idx) = target + && app.session.actions.recording.is_none() + { + actions_cmds::restore_recording(&mut app.session, idx, name, from); + } +} + +/// Whether a paused playback holds a suspended recording. +fn suspended(app: &PhotocraftApp) -> bool { + app.paused_playbacks.paused.values().any(|p| p.record.is_some()) +} + +/// While a step dialog holds a suspended recording, refuse what would lose its call: a new +/// recording (the restore would find recording taken) or deleting/moving actions (the recorded +/// action could go away or change place). The engine checks these against `recording`, which +/// is `None` then. +pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { + if matches!(id, "actions.record" | "actions.delete" | "actions.move") && suspended(app) { + return Err(tl!("Confirm or cancel the open action step dialog first").into()); + } + Ok(()) +} + +/// 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); + 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 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)] +pub(crate) struct PausedPlayback { + action: String, + from: usize, + gate: Option, + /// The recording the paused call suspended: `{index, into, action, from}` (`into` is the + /// name of the action being recorded). A step dialog only closes through + /// `dialogs::confirm` (OK, `↩`, automation) or `dialogs::cancel` (Cancel, Esc, the close + /// box, automation), which end the call through `resume`, `abandoned` or `stopped`, so the + /// recording comes back exactly once. + record: Option, +} + +/// The paused playbacks, by the token their dialog carries. The dialog's fields hold only the +/// token; what plays next, and under which gate, is kept here. +#[derive(Debug, Default)] +pub(crate) struct PausedPlaybacks { + last: u64, + paused: std::collections::HashMap, +} + +impl PausedPlaybacks { + 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.last + } + + fn take(&mut self, token: &Value) -> Option { + token.as_u64().and_then(|token| self.paused.remove(&token)) + } +} + /// 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 +/// confirmed: a control request, synthetic Enter or OK, or a click. pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { - let Some(r) = fields.get(RESUME) else { return Ok(()) }; + let Some(token) = fields.get(RESUME) else { return Ok(()) }; + let Some(paused) = app.paused_playbacks.take(token) else { + 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) { + 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 params = json!({"action": r.get("action").cloned().unwrap_or(Value::Null), "from": r.get("from").cloned().unwrap_or(Value::Null)}); - match play(app, ¶ms) { + let params = json!({"action": paused.action, "from": paused.from}); + let saved = app.session.authorize; + app.session.authorize = paused.gate.or(saved); + let played = play_owned(app, ¶ms, owner); + app.session.authorize = saved; + match played { Ok(v) => { report_play(app, &v); Ok(()) @@ -470,6 +613,7 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) /// A step dialog cancelled: the action stops there, as in Photoshop. pub fn stopped(app: &mut PhotocraftApp, fields: &serde_json::Map) { if fields.contains_key(RESUME) { + abandoned(app, fields); app.ui.status = tl!("Action playback stopped").to_string(); app.ui.status_error = false; } @@ -478,21 +622,58 @@ pub fn stopped(app: &mut PhotocraftApp, fields: &serde_json::Map) /// The Actions entries of the panel group's menu ("…"). pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut act: Option<&str> = None; + let recording = app.session.actions.recording.is_some(); + let any = !app.session.actions.list.is_empty(); + let selected = app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()); + let can_delete = selected && (!recording || app.ui.actions.selected_step.is_some()); + if ui.add_enabled(!recording, egui::Button::new(tl!("New Action…"))).clicked() { + act = Some("new"); + } + if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() { + act = Some("play"); + } + if recording { + if ui.button(tl!("Stop Recording")).clicked() { + act = Some("stopRec"); + } + } else if ui.button(tl!("Start Recording")).clicked() { + act = Some("record"); + } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { + act = Some("delete"); + } + ui.separator(); + if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { + act = Some("stop"); + } + ui.separator(); if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); } - let any = !app.session.actions.list.is_empty(); if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() { act = Some("save"); } - ui.separator(); - let selected = app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()); - if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { - act = Some("stop"); - } let Some(a) = act else { return }; ui.close(); let r = match a { + "new" => { + begin_recording(app, false); + Ok(Value::Null) + } + "record" => { + begin_recording(app, true); + 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) + }), + "delete" => { + delete_selection(app); + Ok(Value::Null) + } "load" => load_dialog(app), "stop" => insert_stop_dialog(app), _ => save_dialog(app, None), @@ -579,8 +760,8 @@ fn delete_selection(app: &mut PhotocraftApp) { /// returns the new name to commit; Escape cancels and keeps the old name. fn rename_field(app: &mut PhotocraftApp, ui: &mut egui::Ui, index: usize, rect: Rect) -> Option { let mut r = app.ui.actions.renaming.take().filter(|r| r.index == index)?; - let id = egui::Id::new(("action-rename-field", index)); - let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(egui::FontId::proportional(12.0))); + let id = egui::Id::unique(("action-rename-field", index)); + let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(egui::FontId::proportional(12.0))).accessible_name(tl!("Name")); if !r.focused { te.request_focus(); // Like Photoshop, the whole name starts selected so typing replaces it. @@ -720,7 +901,7 @@ 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 (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, !collapsed, set)); + hresp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, !collapsed, set)); if hresp.hovered() { ui.painter().rect_filled(hr, 0.0, t.hover.gamma_multiply(0.5)); } @@ -763,7 +944,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let button_mode = app.ui.actions.button_mode; let sel = app.ui.actions.selected == Some(i) && app.ui.actions.selected_step.is_none(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, name)); if button_mode { ui.painter().rect_filled(rect, 3.0, button_fill(swatch, &t)); } else if sel { @@ -886,7 +1067,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let label = step_label(id, params); let selected = app.ui.actions.selected == Some(i) && app.ui.actions.selected_step == Some(step); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &label)); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &label)); if selected { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if response.hovered() { @@ -901,22 +1082,22 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Photoshop's check column: unchecked steps are skipped by playback, Batch and droplets. let check = ui.interact( Rect::from_center_size(pos2(x0 + 30.0, r.center().y), vec2(16.0, 16.0)), - ui.id().with(("action-step-check", i, step)), + ui.scope_id().with(("action-step-check", i, step)), Sense::click(), ); - check.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, format!("{}: {label}", tl!("Include in playback")))); + check.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, format!("{}: {label}", tl!("Include in playback")))); paint_check(ui, check.rect, on, &t); // Photoshop's dialog column: playback shows the step's dialog, seeded with its // recorded values, and continues when it is confirmed. let with_dialog = dialogs.contains(&step); let dialog = ui.interact( Rect::from_center_size(pos2(x0 + 46.0, r.center().y), vec2(16.0, 16.0)), - ui.id().with(("action-step-dialog", i, step)), + ui.scope_id().with(("action-step-dialog", i, step)), Sense::click(), ); // A toggle button, not a check box, so the check column stays the only CheckBox role. dialog.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Button, true, with_dialog, format!("{}: {label}", tl!("Show dialog during playback"))) + egui::WidgetInfo::selected(egui::Role::Button, true, with_dialog, format!("{}: {label}", tl!("Show dialog during playback"))) }); crate::icons::paint(ui, dialog.rect, "message-square", 11.0, if with_dialog { t.icon } else { t.text_faint.gamma_multiply(0.45) }); let dialog = dialog.on_hover_text(tl!("Show dialog during playback")); @@ -975,7 +1156,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { begin_recording(app, true); } let play = crate::icons::button(ui, "play", 24.0, false, tl!("Play selection")); - play.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Play selection"))); + play.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Play selection"))); if play.clicked() { play_idx = playback_selection(app); } @@ -985,14 +1166,14 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let can_delete = app.ui.actions.selected.is_some() && (!recording_now || app.ui.actions.selected_step.is_some()); ui.add_enabled_ui(can_delete, |ui| { let response = crate::icons::button(ui, "trash", 24.0, false, tl!("Delete")); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_delete, tl!("Delete"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_delete, tl!("Delete"))); if response.clicked() { delete_selection(app); } }); let mode_label = if app.ui.actions.button_mode { tl!("Button Mode On") } else { tl!("Button Mode") }; let mode_button = ui.add_enabled(!recording_now, egui::Button::new(mode_label).small()).on_hover_text(tl!("Button Mode")); - mode_button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !recording_now, mode_label)); + mode_button.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, !recording_now, mode_label)); if mode_button.clicked() { app.ui.actions.button_mode = !app.ui.actions.button_mode; } @@ -1013,6 +1194,35 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn panel_menu_lists_load_save_and_existing_action_commands() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut empty_app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + empty_app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut empty = Harness::builder().with_size(vec2(240.0, 280.0)).build_ui_state(|ui, app| panel_menu(app, ui), empty_app); + empty.run_steps(2); + assert!(empty.query_all_by_label("Load Actions…").count() > 0); + assert!(empty.query_all_by_label("New Action…").count() > 0); + assert!(empty.query_all_by_label("Start Recording").count() > 0); + empty.get_by_label("New Action…").click(); + empty.run_steps(2); + assert_eq!(empty.state().session.actions.list.len(), 1); + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.session.actions.list.push(Action::new("Make Layer", vec![("layer.new.layer".into(), json!({}))])); + app.ui.actions.selected = Some(0); + let mut h = Harness::builder().with_size(vec2(240.0, 320.0)).build_ui_state(|ui, app| panel_menu(app, ui), app); + h.run_steps(2); + for label in ["Load Actions…", "Save Actions…", "Insert Stop…", "Play", "Delete", "Start Recording"] { + assert!(h.query_all_by_label(label).count() > 0, "{label}"); + } + let layers = h.state().session.active().unwrap().doc.layers.len(); + h.get_by_label("Play").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1); + } + #[test] fn selected_layer_name_is_visible_during_recording_and_after_stop() { use egui_kittest::{Harness, kittest::Queryable}; @@ -1453,8 +1663,8 @@ mod tests { /// An app with the real importer/exporter and writer whose Save dialogs answer `answers`. fn disk_app(answers: Vec>) -> PhotocraftApp { let services = crate::Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), 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()) @@ -1525,6 +1735,74 @@ mod tests { 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) { + 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 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(); + b.extend_from_slice(&((12 + units.len() * 2) as u32).to_le_bytes()); + b.extend_from_slice(&(units.len() as u32).to_le_bytes()); + 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 item = |code: &[u8; 4], label: &str, d: Option| AtnItem { + event: AtnEvent::Code(*code), + dictionary: label.into(), + descriptor: d, + ..Default::default() + }; + let set = AtnFile { + name: UnicodeString::new_nul("Export"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Invert and save"), + items: vec![item(b"Invr", "Invert", None), item(b"save", "Save", Some(save))], + ..Default::default() + }], + ..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(); + 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 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 _ = std::fs::remove_dir_all(&dir); + r + } + + #[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}"); + 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}"); + assert!(!src_changed); + assert!(written.is_empty(), "{written:?}"); + } + #[test] fn set_folders_group_actions_and_collapse() { use egui_kittest::{Harness, kittest::Queryable}; @@ -1659,7 +1937,7 @@ mod tests { let id = v["paused"]["dialog"].as_u64().unwrap(); let d = app.ui.dialog_mut(id).unwrap(); assert_eq!(d.fields["radius"], 3, "the dialog opens on the recorded values"); - assert_eq!(d.fields[RESUME], json!({"action": "Blur", "from": 2})); + assert!(d.fields[RESUME].is_u64(), "the dialog carries only the pause's token: {}", d.fields[RESUME]); assert!(app.session.active().unwrap().doc.selection.is_none(), "nothing after the dialog ran yet"); report_play(&mut app, &v); assert!(app.ui.status.contains("dialog"), "{}", app.ui.status); @@ -1692,6 +1970,318 @@ mod tests { assert!(v.get("paused").is_none()); } + /// An 8×6 document, a Child action whose first step (a blur) shows its dialog and whose + /// second step adds a "Suffix" layer, and recording into Owner started. + fn recording_with_dialog_child() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 6, "background": "#203040"})).unwrap(); + let mut child = + Action::new("Child", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1.5})), ("layer.new.layer".into(), json!({"name": "Suffix"}))]); + child.dialogs.insert(0); + app.session.actions.list.push(child); + app.run("actions.record", json!({"name": "Owner"})).unwrap(); + app + } + + fn suffixes(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.walk().iter().filter(|(_, _, l)| l.name == "Suffix").count() + } + + fn child_call() -> Vec<(String, Value)> { + vec![("actions.play".to_string(), json!({"action": "Child"}))] + } + + /// Play Child and return the id of its step dialog. + fn pause_child(app: &mut PhotocraftApp) -> u64 { + let v = play(app, &json!({"action": "Child"})).unwrap(); + v["paused"]["dialog"].as_u64().unwrap() + } + + #[test] + fn a_recorded_call_paused_by_a_dialog_records_one_call_and_replays_once() { + let mut app = recording_with_dialog_child(); + let refusal = crate::i18n::t("Confirm or cancel the open action step dialog first").to_string(); + + // Confirm: the dialog command and the resumed rest stay inside the one named call. + let live = app.session.journal.len(); + let id = pause_child(&mut app); + assert!(app.session.actions.recording.is_none(), "recording stays suspended while the step dialog is open"); + let names = |app: &PhotocraftApp| app.session.actions.list.iter().map(|a| a.name.clone()).collect::>(); + let before = names(&app); + assert_eq!(app.run("actions.record", json!({"name": "Intruder"})), Err(refusal.clone()), "no second recording while the call is suspended"); + assert_eq!(app.run("actions.delete", json!({"action": "Owner"})), Err(refusal.clone()), "the suspended owner can't be deleted"); + assert_eq!(app.run("actions.move", json!({"action": "Owner", "to": 0})), Err(refusal.clone()), "the suspended owner can't be moved"); + assert_eq!(names(&app), before, "the refused commands leave the list alone"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.session.actions.recording.is_some(), "recording comes back when the call completes"); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[1].steps, child_call()); + assert_eq!(suffixes(&app), 1); + + // Replaying the recording repeats the call once. + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + let start = app.session.journal.len(); + app.automation_input = true; + let v = play(&mut app, &json!({"action": "Owner"})).unwrap(); + app.automation_input = false; + assert!(v.get("failed").is_none(), "{v}"); + assert_eq!(suffixes(&app), 1, "replay creates the suffix once"); + let edits = |from: usize| -> Vec<(String, Value)> { + app.session.journal[from..].iter().filter(|(id, _)| !id.starts_with("actions.") && id != "file.new").cloned().collect() + }; + let expected = vec![("filter.blur.gaussianBlur".to_string(), json!({"radius": 1.5})), ("layer.new.layer".to_string(), json!({"name": "Suffix"}))]; + assert_eq!(edits(start), expected, "replay journals the blur and the suffix once each, in order"); + assert_eq!(edits(live), expected[..].iter().chain(&expected).cloned().collect::>(), "live then replay"); + + // Cancel: the call ends at the dialog and is recorded once. + app.run("actions.record", json!({"name": "Cancelled"})).unwrap(); + let id = pause_child(&mut app); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(app.session.actions.recording.is_some(), "cancel restores recording"); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[2].steps, child_call()); + } + + #[test] + fn a_failing_step_dialog_command_ends_the_recorded_call_once() { + let mut app = recording_with_dialog_child(); + let id = pause_child(&mut app); + assert!(app.session.actions.recording.is_none(), "recording stays suspended while the step dialog is open"); + app.ui.dialog_mut(id).unwrap().fields.insert("radius".into(), json!("not a radius")); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "the dialog command fails"); + assert!(app.session.actions.recording.is_some(), "recording comes back when the dialog command fails"); + assert_eq!(suffixes(&app), 0, "the action stops at the failed step"); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[1].steps, child_call()); + } + + #[test] + fn an_abandoned_step_dialog_leaves_no_paused_playback() { + let mut app = recording_with_dialog_child(); + let id = pause_child(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("radius".into(), json!("not a radius")); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "the dialog command fails"); + assert!(app.paused_playbacks.paused.is_empty(), "the failed dialog ends its pause"); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[1].steps, child_call(), "the call is recorded once"); + app.run("actions.record", json!({"name": "Next"})).unwrap(); + } + + #[test] + fn a_failing_resumed_playback_ends_the_recorded_call_once() { + let mut app = recording_with_dialog_child(); + let id = pause_child(&mut app); + // The rest of the call can't be found any more: resuming it fails. + app.session.actions.list[0].name = "Renamed".into(); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "the resumed playback fails"); + assert!(app.session.actions.recording.is_some(), "recording comes back when the resumed playback fails"); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[1].steps, child_call()); + } + + #[test] + fn a_suspended_call_is_recorded_into_its_action_after_the_list_changes() { + let mut app = recording_with_dialog_child(); + let id = pause_child(&mut app); + // Something else puts an action where Owner was. + app.session.actions.list.insert(1, Action::new("Bystander", vec![])); + crate::dialogs::confirm(&mut app, id).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + assert!(app.session.actions.list[1].steps.is_empty(), "the action now at the old index gets nothing"); + assert_eq!(app.session.actions.list[2].name, "Owner"); + assert_eq!(app.session.actions.list[2].steps, child_call()); + } + + #[test] + fn escape_on_a_step_dialog_ends_the_recorded_call_once() { + let app = recording_with_dialog_child(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(2); + pause_child(h.state_mut()); + h.run_steps(4); + assert!(h.state().session.actions.recording.is_none(), "recording stays suspended while the step dialog is open"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.dialogs.is_empty(), "Esc closes the step dialog"); + assert!(h.state().session.actions.recording.is_some(), "Esc restores recording"); + let app = h.state_mut(); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[1].steps, child_call()); + } + + #[test] + fn a_step_dialog_keeps_its_continuation_token() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + for (name, steps) in [ + ("First", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1}))]), + ("Second", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1})), ("select.all".into(), json!({}))]), + ] { + let mut action = Action::new(name, steps); + action.dialogs.insert(0); + app.session.actions.list.push(action); + } + let first = play(&mut app, &json!({"action": "First"})).unwrap()["paused"]["dialog"].as_u64().unwrap(); + let second = play(&mut app, &json!({"action": "Second"})).unwrap()["paused"]["dialog"].as_u64().unwrap(); + let first_token = app.ui.dialog_mut(first).unwrap().fields[RESUME].clone(); + let second_token = app.ui.dialog_mut(second).unwrap().fields[RESUME].clone(); + for token in [second_token.clone(), Value::Null, json!("invalid"), json!({"action": "Second", "from": 1})] { + let (request, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"dialog": first, "field": RESUME, "value": token})); + let response = crate::control::handle(&mut app, &egui::Context::default(), &request); + assert!(matches!(response, crate::control::Outcome::Done(ref v) if v["error"].as_str().is_some_and(|e| e.contains("read-only")))); + assert_eq!(app.ui.dialog_mut(first).unwrap().fields[RESUME], first_token); + assert_eq!(app.ui.dialog_mut(second).unwrap().fields[RESUME], second_token); + assert_eq!(app.paused_playbacks.paused.len(), 2); + } + crate::dialogs::confirm(&mut app, first).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_none(), "First must not run Second's remaining step"); + crate::dialogs::confirm(&mut app, second).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some()); + assert!(app.paused_playbacks.paused.is_empty()); + } + + fn recording_with_displace_child() -> PhotocraftApp { + let mut app = recording_with_dialog_child(); + app.background_jobs = false; + app.session.actions.list[0].steps[0] = ("filter.distort.displace".into(), json!({})); + app + } + + fn assert_displace_call_finished(app: &mut PhotocraftApp, suffix_count: usize) { + assert!(!app.file_dialog_open()); + assert!(app.ui.dialogs.is_empty()); + assert!(app.paused_playbacks.paused.is_empty()); + assert!(app.session.actions.recording.is_some()); + assert_eq!(suffixes(app), suffix_count); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[1].steps, child_call(), "record the child call exactly once"); + app.run("actions.record", json!({"name": "Next"})).unwrap(); + } + + #[test] + fn a_displace_step_restores_recording_when_the_map_picker_fails_or_is_cancelled() { + use crate::file_dialog::FileDialogAnswer; + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing.png").to_string_lossy().into_owned(); + for answer in [ + None, + Some(FileDialogAnswer::Unavailable("no picker available".into())), + Some(FileDialogAnswer::Paths(Vec::new())), + Some(FileDialogAnswer::Contents("map.png".into(), Vec::new())), + Some(FileDialogAnswer::Paths(vec![missing])), + ] { + let mut app = recording_with_displace_child(); + let (show, _) = crate::file_dialog::fake(vec![answer]); + app.services.file_dialog = Some(show); + let id = pause_child(&mut app); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.file_dialog_open()); + assert!(app.session.actions.recording.is_none()); + app.poll_file_dialog(&egui::Context::default(), None); + assert_displace_call_finished(&mut app, 0); + } + } + + #[test] + fn a_displace_step_restores_recording_when_the_map_picker_cannot_start() { + let mut app = recording_with_displace_child(); + let id = pause_child(&mut app); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_displace_call_finished(&mut app, 0); + } + + #[test] + fn a_displace_step_resumes_and_records_its_call_once_after_choosing_a_map() { + let mut app = recording_with_displace_child(); + let dir = tempfile::tempdir().unwrap(); + let map = dir.path().join("map.png"); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "png", &Default::default()).unwrap().bytes; + std::fs::write(&map, bytes).unwrap(); + let (show, _) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::Paths(vec![map.to_string_lossy().into_owned()]))]); + app.services.file_dialog = Some(show); + let id = pause_child(&mut app); + crate::dialogs::confirm(&mut app, id).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_displace_call_finished(&mut app, 1); + } + + #[test] + fn a_step_dialog_takes_only_the_steps_values() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut a = Action::new("Blur", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 2, "__command": "select.all", "__label": "Other"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Blur"})).unwrap(); + let d = app.ui.dialog_mut(v["paused"]["dialog"].as_u64().unwrap()).unwrap(); + assert_eq!(d.fields["radius"], 2); + assert_eq!(d.fields["__command"], "filter.blur.gaussianBlur"); + assert_ne!(d.fields["__label"], "Other"); + } + + fn deny_blur(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "filter.blur.gaussianBlur" { Err(photocraft_engine::EngineError::Other("blur denied".into())) } else { Ok(()) } + } + + #[test] + fn a_step_the_gate_denies_opens_no_dialog() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut a = Action::new("Blur", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + app.session.authorize = Some(deny_blur); + let v = play(&mut app, &json!({"action": "Blur"})).unwrap(); + app.session.authorize = None; + assert!(v["failed"]["error"].as_str().is_some_and(|e| e.contains("blur denied")), "{v}"); + assert!(app.ui.dialogs.is_empty(), "no step dialog opened"); + assert!(app.paused_playbacks.paused.is_empty()); + } + + fn deny_select_all(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "select.all" { Err(photocraft_engine::EngineError::Other("select.all denied".into())) } else { Ok(()) } + } + + /// An action an automation client plays (`control::handle` installs the gate, then removes + /// it) pauses at a step dialog; confirming the dialog later, here with synthetic Enter or + /// OK, runs the remaining steps under the same gate. + #[test] + fn a_paused_playback_keeps_its_automation_gate_when_the_dialog_is_confirmed() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut a = Action::new("Seeded", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1})), ("select.all".into(), json!({}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + app.session.authorize = Some(deny_select_all); + let v = play(&mut app, &json!({"action": "Seeded"})).unwrap(); + app.session.authorize = None; + let id = v["paused"]["dialog"].as_u64().unwrap(); + app.automation_input = true; + let _ = crate::dialogs::confirm(&mut app, id); + app.automation_input = false; + assert!(app.session.active().unwrap().doc.selection.is_none(), "the denied step did not run"); + assert!(app.ui.status.contains("select.all denied"), "{}", app.ui.status); + } + + /// The dialog names only its pause: changing its fields can't resume another action. + #[test] + fn a_step_dialog_resumes_only_the_playback_that_paused_it() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut a = Action::new("Blur", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + app.session.actions.list.push(Action::new("Seeded", vec![("select.all".into(), json!({}))])); + let v = play(&mut app, &json!({"action": "Blur"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert(RESUME.into(), json!({"action": "Seeded", "from": 0})); + let r = crate::dialogs::confirm(&mut app, id); + assert!(app.session.active().unwrap().doc.selection.is_none(), "the other action did not play"); + assert!(r.is_err_and(|e| e.contains("paused action"))); + } + #[test] fn button_mode_plays_the_clicked_action_and_hides_its_steps() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 82432879db..18e32e486a 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -179,7 +179,7 @@ pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, to let tokens = crate::theme::Tokens::get(ui.ctx()); ui.painter().circle_filled(response.rect.right_bottom() - egui::vec2(5.5, 5.5), 3.5, color); ui.painter().circle_stroke(response.rect.right_bottom() - egui::vec2(5.5, 5.5), 3.5, egui::Stroke::new(1.0, tokens.field_border)); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tooltip)); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, selected, tooltip)); response } @@ -226,7 +226,7 @@ pub fn owns(fields: &Map) -> bool { /// Root of the dialog editor's view state in egui memory. fn dialog_mem(kind: &str) -> egui::Id { - egui::Id::new(("adjust-dialog", kind)) + egui::Id::unique(("adjust-dialog", kind)) } /// Whether holding Alt turns Cancel into Reset for this adjustment dialog (every kind). @@ -279,11 +279,42 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields)) } +/// 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. +pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { + let id = open(app, command)?; + let kind = command.strip_prefix(PREFIX).unwrap_or_default(); + 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("__")); + dialog.fields.extend(values); + } + Some(id) +} + +/// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a +/// smart filter's settings is not a use of the dialog's own (as `filter_dialog::remember`). +pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map) { + if fields.contains_key("__smartFilter") { + 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); + app.session.prefs.edit(|prefs| prefs.dialogs.insert(command.into(), values)); +} + /// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask -/// selection — both ways of selecting a mask must give the same dropdown (#5335). +/// selection — both ways of selecting a mask must give the same dropdown (#5335). A targeted +/// filter mask is a gray plane too (FILE-215-3). fn isolated(app: &PhotocraftApp) -> Option { - let mask = app.layer_mask_targeted(); - app.session.active().and_then(|st| photocraft_engine::channel_cmds::isolated_curve_channel_for(st, mask)) + let st = app.session.active()?; + if app.filter_mask_targeted() { + return photocraft_engine::channel_cmds::filter_mask_curve_channel(st); + } + photocraft_engine::channel_cmds::isolated_curve_channel_for(st, app.layer_mask_targeted()) } /// The colour mode the Levels/Curves dialog seeds and parses in: a mask, saved channel or @@ -492,16 +523,26 @@ mod tests { /// shows Midtones, keeps Preserve Luminosity and leaves the dialog open. #[test] fn color_balance_dialog_gestures_match_photoshop() { - use egui_kittest::kittest::Queryable; + use egui_kittest::kittest::{NodeT, Queryable}; let (mut h, id) = color_balance_dialog(); - let label = h.query_all_by_label("Cyan · Red").last().map(|n| n.rect()).expect("Cyan · Red row"); + // The slider row's caption, not the field or slider it names. + let label = h + .query_all(egui_kittest::kittest::by().label("Cyan · Red").include_labels()) + .find(|n| n.accesskit_node().role() == egui::accesskit::Role::Label) + .map(|n| n.rect()) + .expect("Cyan · Red row"); let at = egui::pos2(label.left() + 60.0, label.bottom() + 14.0); let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::ALT }; input(&mut h, egui::Modifiers::ALT, &[egui::Event::PointerMoved(at), press(true), press(false), press(true), press(false)]); assert_eq!(field(&h, id, "midtones"), json!([0.0, -30.0, 20.0]), "Alt+double-click zeroes the slider"); assert_eq!(field(&h, id, "shadows"), json!([10, 0, 0]), "other tones untouched"); - let wheel = - |n: f32, m| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, n), phase: egui::TouchPhase::Move, modifiers: m }; + let wheel = |n: f32, m| egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, n), + phase: egui::TouchPhase::Move, + modifiers: m, + }; input(&mut h, egui::Modifiers::NONE, &[egui::Event::PointerMoved(at), wheel(3.0, egui::Modifiers::NONE)]); assert_eq!(field(&h, id, "midtones")[0], json!(3.0), "3 notches up"); input(&mut h, egui::Modifiers::SHIFT, &[egui::Event::PointerMoved(at), wheel(2.0, egui::Modifiers::SHIFT)]); @@ -511,7 +552,7 @@ mod tests { // Alt turns Cancel into Reset. assert!(h.query_by_label("Cancel").is_some() && h.query_by_label("Reset").is_none()); h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("highlights".into(), json!([5, 5, 5])); - let tone = egui::Id::new(("adjust-dialog", "colorBalance")).with("cb-tone"); + let tone = egui::Id::unique(("adjust-dialog", "colorBalance")).with("cb-tone"); h.ctx.data_mut(|d| d.insert_temp(tone, 2usize)); h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); h.run_steps(2); @@ -589,19 +630,101 @@ mod tests { PhotocraftApp::setup_context(&h.ctx, theme); let id = open(h.state_mut(), "image.adjustments.curves").unwrap(); h.run_steps(4); - let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap(); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).unwrap(); let graph = h.ctx.data(|d| d.get_temp::(dialog_mem("curves").with("curves-graph"))).unwrap(); assert!(window.right() - graph.right() < 40.0, "blank right column: window {window:?}, graph {graph:?}"); assert!(graph.width() > 270.0, "keep the graph usable: {graph:?}"); assert!(window.contains_rect(graph)); for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] { - assert!(window.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} inside the window"); + // A caption and the field it names share the label; any of them is the control. + let node = h.query_all_by_label(crate::i18n::t(label)).next().unwrap_or_else(|| panic!("{label} in the dialog")); + assert!(window.contains_rect(node.rect()), "{label} inside the window"); } } } } } + /// #8984: the Curves dialog has a resize grip; its graph grows with the dialog, the controls + /// stay inside it, and it reopens at the size it was left at. + #[test] + fn curves_dialog_resizes_with_its_graph_and_reopens_at_that_size() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + let graph = |h: &Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(dialog_mem("curves").with("curves-graph"))).unwrap(); + let window = |h: &Harness<'_, PhotocraftApp>, id: u64| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).unwrap(); + let id = open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(4); + let (opened, small) = (window(&h, id), graph(&h)); + assert!(small.width() > 270.0 && small.width() < 300.0, "opens at the 300-point graph: {small:?}"); + // Drag the grip (bottom-right corner) 300 points right and 200 down. + let corner = opened.max - egui::vec2(12.0, 12.0); + let by = egui::vec2(300.0, 200.0); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + for i in 1..=4 { + h.event(egui::Event::PointerMoved(corner + by * (i as f32 / 4.0))); + h.step(); + } + h.event(egui::Event::PointerButton { pos: corner + by, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(6); + let (wide, big) = (window(&h, id), graph(&h)); + assert!(big.width() > small.width() + 250.0, "the graph follows the dialog: {small:?} → {big:?}"); + assert!((big.width() - big.height()).abs() < 1.0, "still square: {big:?}"); + assert!(wide.contains_rect(big) && wide.right() - big.right() < 40.0, "no blank column: window {wide:?}, graph {big:?}"); + for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] { + for node in h.get_all_by_label(crate::i18n::t(label)) { + assert!(wide.contains_rect(node.rect()), "{label} label and control stay inside the window"); + } + } + // Cancel, open again: the size it was left at. + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + let again = open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(4); + assert!((graph(&h).width() - big.width()).abs() < 2.0, "reopened at {:?}, left at {big:?}", graph(&h)); + assert!((window(&h, again).width() - wide.width()).abs() < 2.0); + } + + /// #8984: however tall the graph's width would make it, the dialog still fits the window. + #[test] + fn curves_graph_never_pushes_the_dialog_off_a_short_window() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 700.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.ctx.data_mut(|m| m.insert_persisted(egui::Id::unique(("dialog-place", "image.adjustments.curves")).with("size"), egui::vec2(1200.0, 1200.0))); + let id = open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(5); + let graph = h.ctx.data(|d| d.get_temp::(dialog_mem("curves").with("curves-graph"))).unwrap(); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).unwrap(); + assert!(graph.height() <= 700.0 - 360.0, "{graph:?}"); + assert!(window.height() <= 700.0, "{window:?}"); + assert!(window.contains_rect(h.get_by_label("OK").rect())); + // The size the window squeezed it to is not remembered: a larger window gets 1200 again. + let kept = h.ctx.data_mut(|m| m.get_persisted::(egui::Id::unique(("dialog-place", "image.adjustments.curves")).with("size"))); + assert_eq!(kept, Some(egui::vec2(1200.0, 1200.0))); + // Dragged far wider, the dialog stops where the graph stops: no blank column. + let corner = window.max - egui::vec2(12.0, 12.0); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerMoved(corner + egui::vec2(600.0, 0.0))); + h.step(); + h.event(egui::Event::PointerButton { + pos: corner + egui::vec2(600.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(5); + let graph = h.ctx.data(|d| d.get_temp::(dialog_mem("curves").with("curves-graph"))).unwrap(); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).unwrap(); + assert!(window.right() - graph.right() < 40.0, "no blank column: window {window:?}, graph {graph:?}"); + } + #[test] fn cancel_leaves_the_document_untouched() { let mut harness = @@ -659,7 +782,7 @@ mod tests { fields.insert("pencil".into(), json!(true)); fields.insert("points".into(), json!((0..256).collect::>())); h.run_steps(4); - let graph = h.ctx.data(|d| d.get_temp::(egui::Id::new(("adjust-dialog", "curves")).with("curves-graph"))).unwrap(); + let graph = h.ctx.data(|d| d.get_temp::(egui::Id::unique(("adjust-dialog", "curves")).with("curves-graph"))).unwrap(); let at = |i: f32, o: f32| egui::pos2(graph.left() + i / 255.0 * graph.width(), graph.bottom() - o / 255.0 * graph.height()); h.hover_at(at(64.0, 200.0)); h.run_steps(1); @@ -689,7 +812,7 @@ mod tests { let id = open(h.state_mut(), "image.adjustments.curves").unwrap(); h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!([[0, 0], [128, 128], [255, 255]])); h.run_steps(4); - let graph = h.ctx.data(|d| d.get_temp::(egui::Id::new(("adjust-dialog", "curves")).with("curves-graph"))).unwrap(); + let graph = h.ctx.data(|d| d.get_temp::(egui::Id::unique(("adjust-dialog", "curves")).with("curves-graph"))).unwrap(); let a = egui::pos2(graph.left() + 128.0 / 255.0 * graph.width(), graph.bottom() - 128.0 / 255.0 * graph.height()); h.hover_at(a); h.run_steps(1); @@ -1075,7 +1198,7 @@ mod tests { #[test] fn curves_channel_popup_keeps_custom_names_verbatim_in_italian() { let it = crate::i18n::Lang::from_code("it").expect("it registered"); - assert_eq!(crate::i18n::tr(it, "Green"), "Verde", "the test relies on the built-in label"); + let green = crate::i18n::tr(it, "Green"); let _lang = crate::i18n::language_scope(it); let mut app = app_with_image(); app.run("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); @@ -1083,13 +1206,13 @@ mod tests { let mut h = curves_harness(app); let names = popup_channels(&mut h); assert!(names.iter().any(|n| n == "Green"), "custom names stay as entered: {names:?}"); - assert!(!names.iter().any(|n| n == "Verde"), "not translated: {names:?}"); + assert!(green == "Green" || !names.iter().any(|n| n == green), "not translated: {names:?}"); // A targeted colour component keeps its translated built-in label. let mut colour = app_with_image(); colour.run("channel.target", json!({"channel": {"color": 1}})).unwrap(); let mut h = curves_harness(colour); let names = popup_channels(&mut h); - assert!(names.iter().any(|n| n == "Verde"), "built-in labels follow the language: {names:?}"); + assert!(names.iter().any(|n| n == green), "built-in labels follow the language: {names:?}"); } /// Curves open in a harness over `app`. @@ -1105,4 +1228,28 @@ mod tests { 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() } + + /// 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. + #[test] + fn curves_on_a_targeted_filter_mask_is_a_gray_plane() { + 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("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 id = app.session.active().unwrap().active_layer.unwrap(); + app.select_filter_mask_target(Some(id.0)); + assert!(app.filter_mask_targeted()); + let c = isolated(&app).expect("a gray target"); + assert_eq!(c.gray, Some(photocraft_engine::channel_cmds::GraySurface::FilterMask)); + // Its histogram counts the filter mask's levels: half black after a stroke. + app.run("paint.stroke", json!({"points": [[8, 0], [8, 31]], "size": 16, "hardness": 1, "color": "#000000", "target": "filterMask"})).unwrap(); + let surface = photocraft_engine::channel_cmds::GraySurface::FilterMask; + let st = app.session.active().unwrap(); + let h = tone::compute_histograms(&st.doc, HistSource::Channel { layer: id, surface }, photocraft_doc::adjust::ToneSpace::Rgb); + 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); + } } diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 5fe0aa9062..43217a10f6 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -21,7 +21,7 @@ use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::theme::Tokens; use crate::tone::{self, HistSource, Histograms}; -use crate::widgets; +use crate::widgets::{self, hue_color}; /// Kinds edited here (Selective Color and Color Lookup have their own editors; Invert has none). pub const KINDS: [&str; 13] = [ @@ -309,9 +309,9 @@ fn channel_picker_opts(ui: &mut egui::Ui, id: egui::Id, opts: &[(usize, &str)], let width = if opts.len() == 1 { 160.0 } else { 110.0 }; let key = format!("{id:?}-ch"); if translate { - widgets::dropdown(ui, &key, &mut ch, opts, width); + widgets::dropdown(ui, &key, text, &mut ch, opts, width); } else { - widgets::dropdown_literal(ui, &key, &mut ch, opts, width); + widgets::dropdown_literal(ui, &key, text, &mut ch, opts, width); } }); ui.data_mut(|d| d.insert_temp(id, ch)); @@ -357,6 +357,27 @@ fn gradient_bar(p: &egui::Painter, r: Rect, from: Color32, to: Color32, vertical const CURVE_HANDLE_SIZE: f32 = 7.0; const CURVE_GRAPH_PADDING: f32 = 5.0; +/// 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; +/// The smallest and the default largest side of the Curves graph: 300 points was the fixed +/// size until #8984. +const CURVE_SIDE: (f32, f32) = (120.0, 300.0); + +/// The Curves graph's side (#8984): the width it is given, in the Properties panel and in the +/// resizable dialog, and no taller than the window leaves room for beside the other rows. +fn curve_side(ui: &egui::Ui) -> f32 { + let tallest = curve_side_max(ui.ctx()); + let width = ui.available_width(); + if width.is_finite() { width.clamp(CURVE_SIDE.0, tallest) } else { CURVE_SIDE.1 } +} + +/// The largest the Curves graph gets in this window: as tall as the window leaves room for beside +/// the dialog's other rows, and never under the old 300 points. The Curves dialog is no wider. +pub(crate) fn curve_side_max(ctx: &egui::Context) -> f32 { + (ctx.content_rect().height() - CURVES_CHROME).max(CURVE_SIDE.1) +} + fn curve_graph(full: Rect, side: f32) -> Rect { let base = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0)); Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING)) @@ -461,7 +482,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { 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); - let side = ui.available_width().clamp(120.0, 300.0); + let side = curve_side(ui); ui.add_space(4.0); let (full, resp) = ui.allocate_exact_size(vec2(side, side + 14.0), Sense::click_and_drag()); let graph = curve_graph(full, side); @@ -572,9 +593,9 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let i = st.gesture.selected.filter(|i| *i < pts.len() && !pencil); let (mut vi, mut vo) = i.map_or((0.0, 0.0), |i| (pts[i][0], pts[i][1])); label(ui, tl!("Input:")); - let ri = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vi, 0.0..=255.0, "", 52.0)).inner; + let ri = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vi, 0.0..=255.0, "", 52.0).accessible_name(tl!("Input:"))).inner; label(ui, tl!("Output:")); - let ro = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vo, 0.0..=255.0, "", 52.0)).inner; + let ro = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vo, 0.0..=255.0, "", 52.0).accessible_name(tl!("Output:"))).inner; if let Some(i) = i && (ri.changed() || ro.changed()) && curve_edit::move_to(&mut pts, i, [vi, vo]) @@ -747,11 +768,11 @@ fn levels(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let fw = ((w - 16.0) / 3.0).min(70.0); let (mut nb, mut ng, mut nw) = (l[0], l[1], l[2]); let gap = ((w - 3.0 * fw) / 2.0 - 12.0).max(0.0); - let r0 = widgets::value_field(ui, &mut nb, 0.0..=253.0, "", fw); + let r0 = widgets::value_field(ui, &mut nb, 0.0..=253.0, "", fw).accessible_name(tl!("Black")); ui.add_space(gap); - let r1 = widgets::value_field(ui, &mut ng, 0.01..=9.99, "", fw); + let r1 = widgets::value_field(ui, &mut ng, 0.01..=9.99, "", fw).accessible_name(tl!("Gamma")); ui.add_space(gap); - let r2 = widgets::value_field(ui, &mut nw, 2.0..=255.0, "", fw); + let r2 = widgets::value_field(ui, &mut nw, 2.0..=255.0, "", fw).accessible_name(tl!("White")); if r0.changed() { l[0] = nb.round().min(l[2] - 2.0); } @@ -801,9 +822,9 @@ fn levels(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { ui.horizontal(|ui| { let fw = ((w - 16.0) / 3.0).min(70.0); let (mut a, mut c) = (l[3], l[4]); - let ra = widgets::value_field(ui, &mut a, 0.0..=255.0, "", fw); + let ra = widgets::value_field(ui, &mut a, 0.0..=255.0, "", fw).accessible_name(tl!("Black")); ui.add_space((w - 2.0 * fw - 16.0).max(0.0)); - let rc = widgets::value_field(ui, &mut c, 0.0..=255.0, "", fw); + let rc = widgets::value_field(ui, &mut c, 0.0..=255.0, "", fw).accessible_name(tl!("White")); if ra.changed() { l[3] = a.round(); } @@ -848,11 +869,6 @@ fn threshold(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { const RANGE_LABELS: [&str; 6] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"]; -fn hue_color(deg: f32) -> Color32 { - let rgb = egui::ecolor::Hsva::new(deg.rem_euclid(360.0) / 360.0, 1.0, 1.0, 1.0).to_srgb(); - Color32::from_rgb(rgb[0], rgb[1], rgb[2]) -} - /// The Saturation slider's track: grey to the hue at full saturation (UI-217-16). fn saturation_track(hue_deg: f32) -> [Color32; 2] { [Color32::from_gray(128), hue_color(hue_deg)] @@ -886,7 +902,7 @@ fn adjusted_spectrum(v: &Value, n: usize) -> Vec { let rect = photocraft_geom::Rect::new(0, 0, n as i32, 1); let px = (0..n) .map(|i| { - let c = egui::ecolor::Hsva::new(i as f32 / (n - 1).max(1) as f32, 1.0, 1.0, 1.0).to_rgb(); + let c = photocraft_color::convert::hsb_to_rgb([i as f32 / (n - 1).max(1) as f32 * 360.0, 1.0, 1.0]); [c[0], c[1], c[2], 1.0] }) .collect(); @@ -914,7 +930,7 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } ui.horizontal(|ui| { let opts: Vec<(usize, &str)> = std::iter::once((0, tl!("Master"))).chain(RANGE_LABELS.iter().enumerate().map(|(i, l)| (i + 1, *l))).collect(); - ui.add_enabled_ui(!colorize, |ui| widgets::dropdown(ui, &format!("{range_id:?}"), &mut range, &opts, 120.0)); + ui.add_enabled_ui(!colorize, |ui| widgets::dropdown(ui, &format!("{range_id:?}"), tl!("Range"), &mut range, &opts, 120.0)); }); ui.data_mut(|d| d.insert_temp(range_id, range)); let mut e = Edit::default(); @@ -984,8 +1000,10 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } // Spectrum bars: input hues above, adjusted below; the selected range's sliders between. ui.add_space(6.0); - let w = ui.available_width().min(300.0); + // As wide as the sliders above, whatever the panel's width (#9242). + let w = ui.available_width(); let (bars, bresp) = ui.allocate_exact_size(vec2(w, 40.0), Sense::click_and_drag()); + ui.data_mut(|d| d.insert_temp(cx.mem.with("hs-bars"), bars)); let top = Rect::from_min_size(bars.min, vec2(w, 10.0)); let bottom = Rect::from_min_size(pos2(bars.left(), bars.bottom() - 10.0), vec2(w, 10.0)); let p = ui.painter_at(bars.expand(4.0)); @@ -1173,7 +1191,7 @@ fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { if preset.is_none() { opts.insert(0, ("custom".into(), "Custom")); } - let changed = ui.add_enabled_ui(!use_color, |ui| widgets::dropdown(ui, &format!("{mode_id:?}-filter"), &mut sel, &opts, 170.0)).inner; + let changed = ui.add_enabled_ui(!use_color, |ui| widgets::dropdown(ui, &format!("{mode_id:?}-filter"), tl!("Filter:"), &mut sel, &opts, 170.0)).inner; if changed && let Some(f) = PHOTO_FILTERS.iter().find(|f| f.0 == sel) { c = f.2.map(|x| f32::from(x) / 255.0); v["color"] = json!(adjust_params::hex(c)); @@ -1214,7 +1232,7 @@ fn channel_mixer(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } ui.horizontal(|ui| { label(ui, tl!("Output Channel:")); - widgets::dropdown(ui, &format!("{out_id:?}"), &mut out, outs, 110.0); + widgets::dropdown(ui, &format!("{out_id:?}"), tl!("Output Channel:"), &mut out, outs, 110.0); }); ui.data_mut(|d| d.insert_temp(out_id, out)); let key = if mono { "gray" } else { ["red", "green", "blue"][out] }; @@ -1323,7 +1341,7 @@ fn gradient_map_strip(ui: &mut egui::Ui, stops: &mut Vec, cx: &EditorCx, s let mut edit = Edit::default(); let w = ui.available_width().min(300.0); let (outer, resp) = ui.allocate_exact_size(vec2(w, 44.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Gradient stops"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Gradient stops"))); let bar = Rect::from_min_max(outer.min + vec2(8.0, 2.0), pos2(outer.right() - 8.0, outer.top() + 26.0)); let x_of = |loc: f32| bar.left() + loc * bar.width(); let loc_of = |x: f32| ((x - bar.left()) / bar.width().max(1.0)).clamp(0.0, 1.0); @@ -1442,6 +1460,7 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } }); press.after(&combo.response); + let _ = combo.response.accessible_name(tl!("Preset:")); if let Some(p) = picked { stops = p; selected = 0; @@ -1470,7 +1489,7 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { ui.horizontal(|ui| { label(ui, tl!("Location:")); let mut loc = s.0 * 100.0; - let rl = widgets::value_field(ui, &mut loc, 0.0..=100.0, "%", 70.0); + let rl = widgets::value_field(ui, &mut loc, 0.0..=100.0, "%", 70.0).accessible_name(tl!("Location:")); if rl.changed() { location = Some(loc / 100.0); changed = true; @@ -1535,7 +1554,7 @@ pub fn swatches(app: &PhotocraftApp) -> [[f32; 3]; 2] { /// Root of an adjustment layer's Properties editor view state in egui memory. pub fn layer_mem(id: LayerId) -> egui::Id { - egui::Id::new(("adjust-layer", id.0)) + egui::Id::unique(("adjust-layer", id.0)) } /// Kinds whose Properties edits follow Photoshop's history and Reset (measured for Color Balance, diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index d8c6e7953a..2bbff7d596 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -5,7 +5,7 @@ use egui::{Pos2, Rect, vec2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use photocraft_doc::{Adjustment, LayerContent, LayerId}; use serde_json::json; @@ -88,12 +88,17 @@ fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) { } fn label_rect(h: &Harness<'_, PhotocraftApp>, text: &str) -> Option { - // The last match: the Properties header repeats the kind's name above its controls. - h.query_all_by_label(text).last().map(|n| n.rect()) + // The last caption: the Properties header repeats the kind's name above its controls. + // `get_by_label` hides a caption that names a control, so ask for the label node itself; + // a checkbox paints its caption as the control, so fall back to the last matching widget. + h.query_all(egui_kittest::kittest::by().label(text).include_labels()) + .rfind(|n| n.accesskit_node().role() == egui::accesskit::Role::Label) + .map(|n| n.rect()) + .or_else(|| h.query_all_by_label(text).last().map(|n| n.rect())) } fn curves_graph(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> Rect { - h.ctx.data(|d| d.get_temp::(egui::Id::new(("adjust-layer", id.0)).with("curves-graph"))).expect("curves graph drawn") + h.ctx.data(|d| d.get_temp::(egui::Id::unique(("adjust-layer", id.0)).with("curves-graph"))).expect("curves graph drawn") } /// Issue #12: every adjustment kind, on a new document at 8/16/32 bits and in RGB, Grayscale, @@ -182,6 +187,7 @@ fn color_lookup_picker_cycles_luts_with_the_mouse_wheel() { assert!(h.state().live_adjust.is_some(), "arrow navigation selects a preview LUT"); for _ in 0..3 { h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: egui::TouchPhase::Move, @@ -206,6 +212,27 @@ fn curve_of(a: &Adjustment) -> Vec<[f32; 2]> { } } +/// #8984: the Curves graph in Properties grows with the panel instead of stopping at 300 +/// points, stays square, and edits where it is clicked at its new size. +#[test] +fn curves_graph_grows_with_the_properties_panel() { + let mut h = app_harness("curves", "rgb", 8); + let (id, _) = layer(&h); + let narrow = curves_graph(&h, id); + assert!(narrow.width() < 300.0, "{narrow:?}"); + crate::panels::request_dock_width(&h.ctx, 760.0); + h.run_steps(8); + let wide = curves_graph(&h, id); + // 760 points of panel, but the 900-point window leaves room for 540 beside the other rows. + assert!(wide.width() > narrow.width() + 250.0 && wide.height() <= 900.0 - 360.0, "{narrow:?} → {wide:?}"); + assert!((wide.width() - wide.height()).abs() < 1.0, "square: {wide:?}"); + // A point added at the graph's centre lands at 128, 128 of the curve. + click(&mut h, wide.center()); + let c = curve_of(&layer(&h).1); + assert_eq!(c.len(), 3, "{c:?}"); + assert!((c[1][0] - 127.5).abs() < 2.0 && (c[1][1] - 127.5).abs() < 2.0, "{c:?}"); +} + /// Issue #43: pressing on a point selects it (no new point), Delete/Backspace and ⌘/Ctrl-click /// remove points, dragging off the graph removes one, endpoints stay. #[test] @@ -306,7 +333,13 @@ fn wheel_at(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, notches: f32, mods: eg h.event(egui::Event::ModifiersChanged(mods)); h.hover_at(at); h.run_steps(1); - h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, notches), phase: egui::TouchPhase::Move, modifiers: mods }); + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, notches), + phase: egui::TouchPhase::Move, + modifiers: mods, + }); h.run_steps(1); h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); h.run_steps(3); @@ -559,7 +592,7 @@ fn gradient_map_stop_controls_fit_a_narrow_properties_panel_in_every_theme() { return; } let cx = EditorCx { - mem: egui::Id::new("map-layout"), + mem: egui::Id::unique("map-layout"), hist: None, gray: false, swatches: [[0.0; 3], [1.0; 3]], @@ -768,7 +801,7 @@ fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'sta let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state( move |ui, values: &mut serde_json::Value| { let cx = EditorCx { - mem: egui::Id::new("precision"), + mem: egui::Id::unique("precision"), hist: None, gray: false, swatches: [[0.0; 3], [1.0; 3]], @@ -786,6 +819,37 @@ fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'sta h } +/// #9242: the Hue/Saturation spectrum bars stretch with the panel, like the sliders above them. +#[test] +fn hue_saturation_bars_fill_the_panel_width() { + use crate::adjust_editors::{EditorCx, editor}; + for width in [300.0, 600.0] { + let mut h = Harness::builder().with_size(vec2(width, 500.0)).build_ui_state( + |ui, values: &mut serde_json::Value| { + let cx = EditorCx { + mem: egui::Id::unique("hs"), + hist: None, + gray: false, + swatches: [[0.0; 3], [1.0; 3]], + gradients: Vec::new(), + dialog: false, + dynamic_color_sliders: true, + isolated_curve: None, + }; + let avail = ui.available_width(); + editor(ui, "hueSaturation", values, &cx); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("hs-avail"), avail)); + }, + json!({}), + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + let bars = h.ctx.data(|d| d.get_temp::(egui::Id::unique("hs").with("hs-bars"))).expect("bars drawn"); + let avail = h.ctx.data(|d| d.get_temp::(egui::Id::unique("hs-avail"))).unwrap(); + assert!((bars.width() - avail).abs() < 0.5, "{width}: bars {} of {avail}", bars.width()); + } +} + /// Types `text` into the `index`th number field and commits it with Enter. fn type_into_field(h: &mut Harness<'static, serde_json::Value>, index: usize, text: &str) { h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().click(); diff --git a/crates/ui-egui/src/adjust_ui.rs b/crates/ui-egui/src/adjust_ui.rs index b713e9104e..ae6722e574 100644 --- a/crates/ui-egui/src/adjust_ui.rs +++ b/crates/ui-egui/src/adjust_ui.rs @@ -4,6 +4,8 @@ //! values). Selective Color previews live while a slider drags, via `app.live_adjust` carrying //! the full parameter set; Color Lookup commits on every change (its table isn't in the params). +use std::sync::{Arc, Mutex, PoisonError}; + use photocraft_doc::{Adjustment, LayerId}; use photocraft_engine::adjust_cmds::RANGES; use serde_json::{Value, json}; @@ -33,12 +35,12 @@ pub fn selective_color_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: La Some((l, v)) if *l == id && v.get("reds").is_some() => v.clone(), _ => committed, }; - let key = egui::Id::new(("selc-range", id.0)); + let key = egui::Id::unique(("selc-range", id.0)); let mut range: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Colors")).color(t.text_dim)); let opts: Vec<(usize, &str)> = RANGE_LABELS.iter().copied().enumerate().collect(); - if widgets::dropdown(ui, &format!("selc-colors-{}", id.0), &mut range, &opts, 150.0) { + if widgets::dropdown(ui, &format!("selc-colors-{}", id.0), tl!("Color"), &mut range, &opts, 150.0) { ui.data_mut(|d| d.insert_temp(key, range)); } }); @@ -90,14 +92,94 @@ fn browse_color_lookup(app: &mut PhotocraftApp, layer: LayerId) -> Result; + +/// The LUT folder scan kept between frames: the latest finished list of `folder`, and the scan +/// in flight, if any. +#[derive(Clone, Default)] +struct FolderScan { + folder: String, + started: Option, + luts: FolderLuts, + pending: Option>>>, +} + +/// The LUT folder's files (Preferences › File Handling › LUT Folder, #6558). The folder is +/// scanned on a worker thread at most every couple of seconds, since a network share can be +/// slow; until a scan finishes the dropdown shows the previous one. +fn folder_luts(app: &PhotocraftApp, ctx: &egui::Context) -> FolderLuts { + let folder = app.session.prefs().file_handling.lut_folder.clone(); + let now = ctx.input(|input| input.time); + let key = egui::Id::unique("color-lookup-folder"); + let mut scan = ctx.data(|data| data.get_temp::(key)).unwrap_or_default(); + if let Some(done) = scan.pending.as_ref().and_then(|p| p.lock().unwrap_or_else(PoisonError::into_inner).take()) { + scan.luts = done; + scan.pending = None; + } + if scan.folder != folder { + // Another folder: never show the old one's files, and drop its scan's result. + scan = FolderScan { folder: folder.clone(), ..FolderScan::default() }; + } + if scan.pending.is_none() && scan.started.is_none_or(|at| now - at >= 2.0) { + scan.started = Some(now); + scan.pending = Some(start_folder_scan(folder, ctx.clone())); + } + let luts = scan.luts.clone(); + ctx.data_mut(|data| data.insert_temp(key, scan)); + luts +} + +/// Scan `folder` on a worker thread (on the web, where there are none, right here: it lists +/// nothing there anyway). +fn start_folder_scan(folder: String, ctx: egui::Context) -> Arc>> { + let result = Arc::new(Mutex::new(None)); + let shared = Arc::clone(&result); + let work = move || { + let luts = photocraft_engine::adjust_cmds::folder_luts(&folder); + *shared.lock().unwrap_or_else(PoisonError::into_inner) = Some(luts); + ctx.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new().name("lut-folder-scan".into()).spawn(work).is_err() { + // No thread: list nothing this time; the next scan retries. + *result.lock().unwrap_or_else(PoisonError::into_inner) = Some(Vec::new()); + } + #[cfg(target_arch = "wasm32")] + work(); + result +} + +/// The dropdown key for the current look: a built-in id, a LUT folder file, `none` or `custom`. +fn current_lookup(name: &str, has_lut: bool, folder: &[(String, String)]) -> String { + if !has_lut { + return "none".into(); + } + if let Some((id, _)) = photocraft_engine::adjust_cmds::LOOKS.iter().find(|b| b.1 == name) { + return id.to_string(); + } + folder.iter().find(|(file, _)| file == name).map_or_else(|| "custom".into(), |(_, path)| format!("{FOLDER_LUT}{path}")) +} + +/// `layer.setAdjustment` params for a dropdown key: a folder file is read and embedded like +/// Load 3D LUT… does; anything else is a built-in id or `none`. +fn lookup_params(key: &str) -> Value { + match key.strip_prefix(FOLDER_LUT) { + Some(path) => json!({"file": path}), + None => json!({"lut": key}), + } +} + pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &Adjustment) { let t = Tokens::get(ui.ctx()); let Adjustment::ColorLookup { name, lut, size, tetrahedral, dither } = adj else { return; }; let builtins = photocraft_engine::adjust_cmds::LOOKS; - let current = - if lut.is_none() { "none".to_string() } else { builtins.iter().find(|b| b.1 == name).map_or_else(|| "custom".to_string(), |b| b.0.to_string()) }; + let folder = folder_luts(app, ui.ctx()); + let current = current_lookup(name, lut.is_some(), &folder); let current = app .live_adjust .as_ref() @@ -108,13 +190,14 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer let custom_label = format!("{name} ({size}³)"); let mut opts: Vec<(String, &str)> = vec![("load".into(), tl!("Load 3D LUT…")), ("none".into(), tl!("None"))]; opts.extend(builtins.iter().map(|(id, label)| (id.to_string(), *label))); + opts.extend(folder.iter().map(|(file, path)| (format!("{FOLDER_LUT}{path}"), file.as_str()))); if current == "custom" { opts.push(("custom".into(), custom_label.as_str())); } let mut sel = current.clone(); let mut params: Option = None; let mut browse = false; - let cycle_key = egui::Id::new(("color-lookup-cycle", id.0)); + let cycle_key = egui::Id::unique(("color-lookup-cycle", id.0)); let cycle_input = ui.input(|input| { input.raw.events.iter().any(|event| { matches!(event, egui::Event::MouseWheel { .. }) @@ -124,17 +207,19 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer let mut cycling_preview = false; ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("3D LUT File")).color(t.text_dim)); - let (chosen, _) = widgets::dropdown_wheel_hovered(ui, &format!("clrl-{}", id.0), &mut sel, &opts, 170.0); + let (chosen, _) = widgets::dropdown_wheel_hovered(ui, &format!("clrl-{}", id.0), tl!("Color"), &mut sel, &opts, 170.0); if !chosen.is_empty() { if cycle_input { - if sel != "load" && sel != "custom" { + // ponytail: wheel previews cover the built-ins only; a folder LUT would be re-read + // from disk on every step. Pick it with a click. + if sel != "load" && sel != "custom" && !sel.starts_with(FOLDER_LUT) { app.live_adjust = Some((id, json!({"lut": sel}))); cycling_preview = true; } } else if sel == "load" { browse = true; } else if sel != current && sel != "custom" { - params = Some(json!({"lut": sel})); + params = Some(lookup_params(&sel)); } } }); @@ -219,6 +304,43 @@ mod lookup_picker_tests { ); } + /// #6558: a LUT folder file is offered by name, and picking it embeds it. + #[test] + fn lut_folder_files_are_offered_and_embedded() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let path = dir.join("film.cube"); + std::fs::write(&path, CUBE).unwrap(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("prefs.set", json!({"path": "fileHandling.lutFolder", "value": dir.to_str().unwrap()})).unwrap(); + // The scan runs on a worker thread: ask again until it has finished. + let ctx = egui::Context::default(); + let mut folder = folder_luts(&app, &ctx); + for _ in 0..500 { + if !folder.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + folder = folder_luts(&app, &ctx); + } + let key = format!("{FOLDER_LUT}{}", path.to_str().unwrap()); + assert_eq!(folder, [("film.cube".to_string(), path.to_str().unwrap().to_string())]); + app.run("file.new", json!({"width": 16, "height": 12})).unwrap(); + let layer = app.run("layer.newAdjustmentLayer.colorLookup", json!({})).unwrap()["layer"].as_u64().unwrap(); + let mut p = lookup_params(&key); + p["layer"] = json!(layer); + app.run("layer.setAdjustment", p).unwrap(); + let photocraft_doc::LayerContent::Adjustment(Adjustment::ColorLookup { name, lut, .. }) = + &app.session.active().unwrap().doc.layer(LayerId(layer)).unwrap().content + else { + panic!("not a Color Lookup layer") + }; + assert!(lut.is_some()); + assert_eq!(current_lookup(name, true, &folder), key); + assert_eq!(current_lookup(name, true, &[]), "custom"); + assert_eq!(lookup_params("none"), json!({"lut": "none"})); + } + #[test] fn invalid_lut_does_not_modify_the_existing_adjustment() { let (dialog, _) = file_dialog::fake(vec![Some(FileDialogAnswer::Contents("broken.cube".into(), b"invalid".to_vec()))]); diff --git a/crates/ui-egui/src/align_bar.rs b/crates/ui-egui/src/align_bar.rs new file mode 100644 index 0000000000..d663283e02 --- /dev/null +++ b/crates/ui-egui/src/align_bar.rs @@ -0,0 +1,76 @@ +//! The Move tool's Align and Distribute controls (Photoshop shows them whenever the Move tool is +//! active; they enable once enough layers are selected). Every button dispatches an existing +//! `layer.align.*` / `layer.distribute.*` command. + +use serde_json::json; + +use crate::theme::Tokens; +use crate::{PhotocraftApp, icons}; + +/// (command id, icon, label). The labels are the Layer menu's, already translated. +const ALIGN: [(&str, &str, &str); 6] = [ + ("layer.align.leftEdges", "align-edge-left", "Left Edges"), + ("layer.align.horizontalCenters", "align-edge-hcenter", "Horizontal Centers"), + ("layer.align.rightEdges", "align-edge-right", "Right Edges"), + ("layer.align.topEdges", "align-edge-top", "Top Edges"), + ("layer.align.verticalCenters", "align-edge-vcenter", "Vertical Centers"), + ("layer.align.bottomEdges", "align-edge-bottom", "Bottom Edges"), +]; + +const DISTRIBUTE: [(&str, &str, &str); 8] = [ + ("layer.distribute.topEdges", "distribute-top", "Top Edges"), + ("layer.distribute.verticalCenters", "distribute-vcenter", "Vertical Centers"), + ("layer.distribute.bottomEdges", "distribute-bottom", "Bottom Edges"), + ("layer.distribute.leftEdges", "distribute-left", "Left Edges"), + ("layer.distribute.horizontalCenters", "distribute-hcenter", "Horizontal Centers"), + ("layer.distribute.rightEdges", "distribute-right", "Right Edges"), + ("layer.distribute.horizontally", "distribute-horizontal", "Horizontally"), + ("layer.distribute.vertically", "distribute-vertical", "Vertically"), +]; + +/// The six align icons followed by a menu holding both groups with icons and names. +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() { + let _ = app.run(cmd, json!({})); + } + } + ui.add_space(6.0); + let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute")); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(200.0); + let mut section = |ui: &mut egui::Ui, title: &str, items: &[(&str, &str, &str)]| { + ui.label(egui::RichText::new(title).small().color(t.text_dim)); + for (cmd, icon, label) in items { + let on = app.session.is_enabled(cmd); + let tint = if on { t.icon } else { t.text_dim }; + let button = egui::Button::image_and_text(icons::image(icon, 16.0, tint), tl!(label)); + if ui.add_enabled(on, button).clicked() { + let _ = app.run(cmd, json!({})); + ui.close(); + } + } + }; + section(ui, tl!("Align"), &ALIGN); + ui.separator(); + section(ui, tl!("Distribute"), &DISTRIBUTE); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_button_is_a_live_command_with_an_icon() { + let specs = photocraft_engine::command_specs(); + for (cmd, icon, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) { + assert!(specs.iter().any(|c| c.id == *cmd), "{cmd} is not a command"); + assert!(icons::exists(icon), "{icon} is not embedded"); + } + } +} diff --git a/crates/ui-egui/src/alt_click_tests.rs b/crates/ui-egui/src/alt_click_tests.rs index 5e0e9d683b..2bbc1a673e 100644 --- a/crates/ui-egui/src/alt_click_tests.rs +++ b/crates/ui-egui/src/alt_click_tests.rs @@ -111,6 +111,87 @@ fn alt_click_with_the_brush_samples_the_foreground() { assert_eq!(h.state().session.tools.foreground, [0.0, 1.0, 0.0, 1.0], "⌥-click sampled green"); } +/// Alt-click sampling respects the canvas view transform without changing document pixels. +#[test] +fn mixer_alt_click_samples_the_tip_through_zoomed_rotated_canvas_input() { + for (zoom, rotation) in [(0.5, 0.0), (1.0, 0.0), (2.0, 30.0)] { + let mut app = two_colours(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run( + "tools.setBrush", + json!({"brush":{"size":24,"roundness":0.4,"hardness":1.0,"pressureSize":false,"mixer":{"wet":0.0,"mix":0.0,"sampleAllLayers":true}}}), + ) + .unwrap(); + let mut h = harness(app); + h.state_mut().ui.tool = Tool::MixerBrush; + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].rotation = rotation; + h.run_steps(2); + let before = h.state().session.active().unwrap().doc.clone(); + let steps = h.state().session.active().unwrap().history.past_len(); + click(&mut h, 100.0, 50.0, Modifiers::ALT); + let state = &h.state().session.tools.mixer; + let load = state.spatial.as_ref().expect("Alt loads a spatial brush tip"); + assert!(load.iter().any(|c| c[0] > 0.9 && c[1] < 0.1 && c[3] > 0.9)); + assert!(load.iter().any(|c| c[1] > 0.9 && c[0] < 0.1 && c[3] > 0.9)); + assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps); + click(&mut h, 40.0, 50.0, Modifiers::NONE); + let st = h.state().session.active().unwrap(); + let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!(surface.rgba(34, 50)[0] > 0.9 && surface.rgba(46, 50)[1] > 0.9, "loaded halves follow document coordinates at {zoom}x/{rotation}°"); + assert_eq!(st.history.past_len(), steps + 1); + assert!(crate::alt_grab::alt_click_tool(Tool::MixerBrush, Modifiers::ALT)); + assert!(!crate::alt_grab::alt_click_tool(Tool::MixerBrush, Modifiers::ALT | Modifiers::CTRL)); + } +} + +/// Native pointer release commits the shown mixer result before restoring a spring-loaded tool. +#[test] +fn mixer_raw_drag_commits_live_pixels_before_a_spring_tool_returns() { + for spring in [false, true] { + let mut app = two_colours(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setColors", json!({"foreground":"#0000ff"})).unwrap(); + app.run( + "tools.setBrush", + json!({"brush":{"size":24,"pressureSize":false,"mixer":{"wet":1.0,"mix":0.5,"sampleAllLayers":true},"smoothing":{"amount":0.0}}}), + ) + .unwrap(); + let mut h = harness(app); + h.state_mut().ui.tool = Tool::MixerBrush; + let before = h.state().session.active().unwrap().doc.clone(); + let steps = h.state().session.active().unwrap().history.past_len(); + let start = screen(&h, 40.0, 50.0); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + button(&mut h, start, true, Modifiers::NONE); + if spring { + // A temporary Mixer Brush's key was released while its mouse drag is held. + h.state_mut().ui.spring = + Some(crate::spring_tools::Spring { previous: Tool::Zoom, tool: Tool::MixerBrush, keys: vec![], since: 0.0, used: true, released: true }); + } + let end = screen(&h, 80.0, 50.0); + h.event(egui::Event::PointerMoved(end)); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::MixerBrush); + let shown = crate::canvas::display_doc(h.state_mut(), 0).0; + let id = h.state().session.active().unwrap().active_layer.unwrap(); + let paint = shown.layer(id).unwrap().surface().unwrap(); + let mixed = paint.rgba(60, 50); + assert!(mixed[0] > 0.0 && mixed[2] > 0.0 && mixed[3] > 0.0, "real red/blue mixing while held: {mixed:?}"); + assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + button(&mut h, end, false, Modifiers::NONE); + h.run_steps(2); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layer(id).unwrap().surface().unwrap(), paint); + assert_eq!(st.history.past_len(), steps + 1); + assert_eq!(h.state().ui.tool, if spring { Tool::Zoom } else { Tool::MixerBrush }); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(*h.state().session.active().unwrap().doc, *before); + } +} + #[test] fn alt_click_sets_the_clone_source_and_a_drag_paints_it() { let mut h = harness(two_colours()); diff --git a/crates/ui-egui/src/alt_grab.rs b/crates/ui-egui/src/alt_grab.rs index fc0f3f1c8e..17dbec5cca 100644 --- a/crates/ui-egui/src/alt_grab.rs +++ b/crates/ui-egui/src/alt_grab.rs @@ -29,7 +29,7 @@ struct Watch { /// Tools whose ⌥-click does something: the ⌥ Eyedropper, the clone source point and Zoom Out. pub(crate) fn alt_click_tool(tool: Tool, mods: egui::Modifiers) -> bool { - crate::canvas::alt_samples(tool, mods) || (mods.alt && !mods.ctrl && matches!(tool, Tool::CloneStamp | Tool::Healing | Tool::Zoom)) + crate::canvas::alt_samples(tool, mods) || (mods.alt && !mods.ctrl && matches!(tool, Tool::MixerBrush | Tool::CloneStamp | Tool::Healing | Tool::Zoom)) } /// The status-bar message for an ⌥-click the desktop took. diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index d62de17348..f3c1594b7b 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -530,7 +530,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo if !m.count_groups.is_empty() { let mut gi = m.active_count_group.min(m.count_groups.len() - 1); let opts: Vec<(usize, &str)> = m.count_groups.iter().enumerate().map(|(i, g)| (i, g.name.as_str())).collect(); - if crate::widgets::dropdown(ui, "count-group", &mut gi, &opts, 140.0) { + if crate::widgets::dropdown(ui, "count-group", tl!("Group"), &mut gi, &opts, 140.0) { let _ = app.run("count.setGroup", json!({"group": gi, "active": true})); } let g = &m.count_groups[gi]; @@ -563,9 +563,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::vline(ui, 22.0); let (mut ms, mut ls) = (g.marker_size as f32, g.label_size as f32); ui.label(RichText::new(tl!("Marker Size")).color(t.text_dim).size(11.0)); - let a = crate::widgets::value_field(ui, &mut ms, 1.0..=10.0, "", 40.0).changed(); + let a = crate::widgets::value_field(ui, &mut ms, 1.0..=10.0, "", 40.0).accessible_name(tl!("Marker Size")).changed(); ui.label(RichText::new(tl!("Label Size")).color(t.text_dim).size(11.0)); - let b = crate::widgets::value_field(ui, &mut ls, 8.0..=72.0, "", 44.0).changed(); + let b = crate::widgets::value_field(ui, &mut ls, 8.0..=72.0, "", 44.0).accessible_name(tl!("Label Size")).changed(); if a || b { let _ = app.run("count.setGroup", count_size_params(ms, ls)); } @@ -574,8 +574,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } Tool::Note => { let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(tl!("Author:")).color(t.text_dim).size(11.0)); - ui.add(egui::TextEdit::singleline(&mut app.ui.analysis.note_author).desired_width(120.0)); + let author_label = ui.label(RichText::new(tl!("Author:")).color(t.text_dim).size(11.0)); + ui.add(egui::TextEdit::singleline(&mut app.ui.analysis.note_author).desired_width(120.0)).labelled_by(author_label.id); ui.label(RichText::new(tl!("Color:")).color(t.text_dim).size(11.0)); let rgb = app.ui.analysis.note_color; if crate::widgets::color_swatch_button(ui, color32(rgb), tl!("Color")).clicked() { @@ -653,7 +653,7 @@ pub(crate) fn panel_window(app: &PhotocraftApp, ctx: &egui::Context, id: &str, t let t = Tokens::get(ctx); let canvas = app.last_canvas_rect; egui::Window::new(title) - .id(egui::Id::new(id)) + .id(egui::Id::unique(id)) .title_bar(false) .resizable(false) .movable(!app.session.prefs().workspace_locked) @@ -789,7 +789,7 @@ fn notes_panel(app: &mut PhotocraftApp, ctx: &egui::Context) { let note = ¬es[i]; ui.label(RichText::new(if note.author.is_empty() { tl!("No author").to_string() } else { note.author.clone() }).color(t.text_dim).size(11.0)); let mut text = note.text.clone(); - if ui.add(egui::TextEdit::multiline(&mut text).desired_rows(6).desired_width(f32::INFINITY)).changed() { + if ui.add(egui::TextEdit::multiline(&mut text).desired_rows(6).desired_width(f32::INFINITY)).accessible_name(tl!("Notes")).changed() { edit = Some(json!({"index": i, "text": text, "coalesce": format!("note-{i}")})); } } @@ -837,7 +837,7 @@ fn num_field(ui: &mut egui::Ui, f: &mut Map, key: &str, label: &s ui.horizontal(|ui| { ui.add_sized(vec2(110.0, 18.0), egui::Label::new(RichText::new(tl!(&label)).color(t.text_dim).size(11.0))); let mut v = f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; - if crate::widgets::value_field(ui, &mut v, range, suffix, 80.0).changed() { + if crate::widgets::value_field(ui, &mut v, range, suffix, 80.0).accessible_name(tl!(label)).changed() { f.insert(key.into(), json!(v)); } }); @@ -849,7 +849,7 @@ fn choice(ui: &mut egui::Ui, f: &mut Map, key: &str, label: &str, ui.add_sized(vec2(110.0, 18.0), egui::Label::new(RichText::new(tl!(&label)).color(t.text_dim).size(11.0))); let mut cur = f.get(key).and_then(Value::as_str).unwrap_or(opts[0].0).to_string(); let list: Vec<(String, &str)> = opts.iter().map(|(k, l)| (k.to_string(), *l)).collect(); - if crate::widgets::dropdown(ui, &format!("an-{key}"), &mut cur, &list, 120.0) { + if crate::widgets::dropdown(ui, &format!("an-{key}"), tl!(label), &mut cur, &list, 120.0) { f.insert(key.into(), json!(cur)); } }); @@ -887,7 +887,7 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { }; let mut result: Option = None; egui::Window::new(title) - .id(egui::Id::new("analysis-dialog")) + .id(egui::Id::unique("analysis-dialog")) .collapsible(false) .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) @@ -904,9 +904,10 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { num_field(ui, &mut f, "pixelLength", tl!("Pixel Length:"), 0.001..=1e7, ""); num_field(ui, &mut f, "logicalLength", tl!("Logical Length:"), 0.0001..=1e7, ""); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new(RichText::new(tl!("Logical Units:")).color(t.text_dim).size(11.0))); + let units_label = + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(RichText::new(tl!("Logical Units:")).color(t.text_dim).size(11.0))); let mut u = f.get("units").and_then(Value::as_str).unwrap_or("pixels").to_string(); - if ui.add(egui::TextEdit::singleline(&mut u).desired_width(80.0)).changed() { + if ui.add(egui::TextEdit::singleline(&mut u).desired_width(80.0)).labelled_by(units_label.id).changed() { f.insert("units".into(), json!(u)); } }); diff --git a/crates/ui-egui/src/ants.rs b/crates/ui-egui/src/ants.rs new file mode 100644 index 0000000000..2af2e7bd08 --- /dev/null +++ b/crates/ui-egui/src/ants.rs @@ -0,0 +1,314 @@ +//! Marching ants: the selection outline, drawn the way Photoshop draws it (#7489). +//! +//! Everything is laid out in device pixels, not egui points. An edge is a band `width` pixels +//! thick on the pixel boundary nearest to it (one pixel at 100 %, two up to 200 %, …), and each +//! of its pixels is black or white by a dash pattern fixed to the screen: dashes and gaps of +//! `4 · width` pixels along the edge, each row of a thicker band shifted `width` pixels against +//! the row beside it, so a dash ends on a slant. The pattern slides over time (the march). +//! +//! A point is a fractional number of pixels on a scaled display (125 %, 150 %), so the ants used to +//! be snapped to a grid the screen doesn't have: one edge of a rectangle came out 2 px thick and the +//! next 1 px, the dashes 5 px long here and 4 px there. +//! +//! Horizontal and vertical edges go in one mesh of pixel-aligned quads, which egui draws as it is: +//! a line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen whole +//! (about 10⁵ segments, #3846). A slanted edge (a transformed outline, an ellipse, a lasso path) +//! stays a line shape, which egui antialiases. + +use egui::{Color32, Mesh, Pos2, Rect, Shape, Stroke, pos2}; + +/// Dash (and gap) length, in line widths. +const DASH: i64 = 4; +/// How fast the ants march, in line widths per second. +const SPEED: f64 = 10.0; +/// Longer than any line that fits the view: only its part in view is dashed. +const LONG: f32 = 8192.0; + +/// The ants on a display of `ppp` device pixels per point, at one moment of the march. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Style { + ppp: f32, + /// Line thickness, in device pixels. + width: i64, + /// Dash length, in device pixels. + dash: i64, + /// How far the pattern has marched, in device pixels. + phase: i64, +} + +impl Style { + /// The ants at frame time `time` (s). + pub(crate) fn new(ppp: f32, time: f64) -> Self { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + // A hair over a whole scale (a windowing system's rounding) is still that scale. + let width = ((ppp - 0.01).ceil() as i64).clamp(1, 8); + let dash = DASH * width; + let time = if time.is_finite() { time.max(0.0) } else { 0.0 }; + let phase = ((time * SPEED * width as f64) % (2 * dash) as f64) as i64; + Self { ppp, width, dash, phase } + } + + /// The black runs of `[lo, hi)` along one pixel row (or column) of a band. A pixel is black + /// where `(s + phase) mod 2·dash < dash`, `s` being its position along the edge plus `off`, + /// `width` times its position across it: `x + w·y` on a horizontal edge, `y + w·x` on a + /// vertical one. + fn runs(&self, lo: i64, hi: i64, off: i64) -> impl Iterator + '_ { + let period = 2 * self.dash; + let first = lo - (lo + off + self.phase).rem_euclid(period); + std::iter::successors(Some(first), move |s| Some(s + period)) + .take_while(move |&s| s < hi) + .map(move |s| (s.max(lo), (s + self.dash).min(hi))) + .filter(|(s, e)| e > s) + } +} + +/// The ants along `lines` (screen points) inside `clip`: a mesh of the horizontal and vertical +/// edges, and line shapes for the slanted ones. `snap_slanted` puts a slanted edge's ends on pixel +/// centres too: a traced outline in a rotated view, a staircase whose steps must meet. A drawn curve +/// (an ellipse, a lasso path) keeps its own points, or its nearly flat arcs turn into 1 px stairs +/// whose antialiased steps wash out to grey. +pub(crate) fn shapes(lines: impl Iterator, clip: Rect, style: Style, snap_slanted: bool) -> (Mesh, Vec) { + let Style { ppp, width: w, dash, .. } = style; + let mut mesh = Mesh::default(); + let mut slanted = Vec::new(); + // Device pixels → a rectangle in points. + let mut quad = |x0: i64, y0: i64, x1: i64, y1: i64, color: Color32| { + let r = Rect::from_min_max(pos2(x0 as f32 / ppp, y0 as f32 / ppp), pos2(x1 as f32 / ppp, y1 as f32 / ppp)); + mesh.add_colored_rect(r, color); + }; + // The view in device pixels, a dash period wider: a band is cut to it so a long edge costs + // what its visible part does, and the pattern is fixed to the screen so the cut doesn't move it. + let view = clip.expand(2.0 * dash as f32 / ppp); + let (vx0, vy0) = ((view.min.x * ppp).floor() as i64, (view.min.y * ppp).floor() as i64); + let (vx1, vy1) = ((view.max.x * ppp).ceil() as i64, (view.max.y * ppp).ceil() as i64); + // The first pixel of a band across its boundary: centred on it, the odd one after it. + let half = w / 2; + for (pa, pb) in lines { + // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon). + if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) { + continue; + } + let (pa, pb) = if pa.distance(pb) > LONG { + let Some(inside) = crate::canvas::clip_line(view, pa, pb) else { continue }; + inside + } else { + (pa, pb) + }; + let (da, db) = (pa * ppp, pb * ppp); + let (ax, ay, bx, by) = (da.x.round() as i64, da.y.round() as i64, db.x.round() as i64, db.y.round() as i64); + if (ax, ay) == (bx, by) { + continue; + } + if ay == by { + // Horizontal: rows `y0..y0 + w`, from one end's band to the other's, so edges meet + // at corners. + let (x0, x1) = ((ax.min(bx) - half).max(vx0), (ax.max(bx) - half + w).min(vx1)); + let y0 = ay - half; + if x1 <= x0 { + continue; + } + quad(x0, y0, x1, y0 + w, Color32::WHITE); + for y in y0..y0 + w { + for (s, e) in style.runs(x0, x1, w * y) { + quad(s, y, e, y + 1, Color32::BLACK); + } + } + } else if ax == bx { + let (y0, y1) = ((ay.min(by) - half).max(vy0), (ay.max(by) - half + w).min(vy1)); + let x0 = ax - half; + if y1 <= y0 { + continue; + } + quad(x0, y0, x0 + w, y1, Color32::WHITE); + for x in x0..x0 + w { + for (s, e) in style.runs(y0, y1, w * x) { + quad(x, s, x + 1, e, Color32::BLACK); + } + } + } else { + // On the middle of the band a snapped straight edge would be. + let centre = w as f32 / 2.0 - half as f32; + let (da, db) = if snap_slanted { (pos2(ax as f32 + centre, ay as f32 + centre), pos2(bx as f32 + centre, by as f32 + centre)) } else { (da, db) }; + let len = da.distance(db); + let dir = (db - da) / len; + let stroke = |color| Stroke::new(w as f32 / ppp, color); + slanted.push(Shape::line_segment([da / ppp, db / ppp], stroke(Color32::WHITE))); + // Phased by screen position, like the straight edges, so dashes line up across joins. + let period = (2 * dash) as f32; + let mut t = -(da.x.floor() + da.y.floor() + style.phase as f32).rem_euclid(period); + while t < len { + let (s0, s1) = (t.max(0.0), (t + dash as f32).min(len)); + if s1 > s0 { + slanted.push(Shape::line_segment([(da + dir * s0) / ppp, (da + dir * s1) / ppp], stroke(Color32::BLACK))); + } + t += period; + } + } + } + (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)); + } +} + +/// The segments of the polyline `points`, back to the first when `closed`. +pub(crate) fn polyline(points: &[Pos2], closed: bool) -> impl Iterator + '_ { + let back = points.first().zip(points.last()).filter(|_| closed && points.len() > 2).map(|(f, l)| (*l, *f)); + points.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).chain(back) +} + +/// Draw animated ants along the polyline `points` (screen points), and keep them marching. +pub(crate) fn draw_polyline(painter: &egui::Painter, points: &[Pos2], closed: bool) { + let time = painter.ctx().input(|i| i.time); + painter.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + draw(painter, polyline(points, closed), time, false); +} + +#[cfg(test)] +mod tests { + use super::*; + + const VIEW: Rect = Rect { min: pos2(0.0, 0.0), max: pos2(800.0, 600.0) }; + + /// The mesh's quads in device pixels, `[x0, y0, x1, y1, black]`, checking each lands exactly + /// on the pixel grid. + fn quads(mesh: &Mesh, ppp: f32) -> Vec<[i64; 5]> { + mesh.vertices + .chunks(4) + .map(|q| { + let px: Vec<[f32; 2]> = q.iter().map(|v| [v.pos.x * ppp, v.pos.y * ppp]).collect(); + for p in &px { + assert!((p[0] - p[0].round()).abs() < 1e-3 && (p[1] - p[1].round()).abs() < 1e-3, "off the pixel grid at {ppp}x: {p:?}"); + } + let xs = px.iter().map(|p| p[0].round() as i64); + let ys = px.iter().map(|p| p[1].round() as i64); + let black = q[0].color == Color32::BLACK; + [xs.clone().min().unwrap(), ys.clone().min().unwrap(), xs.max().unwrap(), ys.max().unwrap(), i64::from(black)] + }) + .collect() + } + + /// The black pixels of a rasterised mesh. + fn black_pixels(q: &[[i64; 5]]) -> std::collections::BTreeSet<(i64, i64)> { + q.iter().filter(|q| q[4] == 1).flat_map(|&[x0, y0, x1, y1, _]| (x0..x1).flat_map(move |x| (y0..y1).map(move |y| (x, y)))).collect() + } + + #[test] + fn the_line_is_whole_device_pixels_thick() { + for (ppp, w) in [(1.0, 1), (1.0001, 1), (1.25, 2), (1.5, 2), (1.75, 2), (2.0, 2), (2.5, 3), (3.0, 3), (0.0, 1), (f32::NAN, 1)] { + assert_eq!(Style::new(ppp, 0.0).width, w, "@{ppp}x"); + assert_eq!(Style::new(ppp, 0.0).dash, 4 * w); + } + } + + /// The bug in #7489: at 125 % one edge was 2 px and the next 1 px. + #[test] + fn every_edge_of_a_rectangle_is_as_thick_as_the_others_at_a_fractional_scale() { + for ppp in [1.0f32, 1.25, 1.5, 1.75, 2.0] { + let style = Style::new(ppp, 0.0); + // Corners at odd and even points, which a fractional scale puts at different fractions + // of a pixel. + let r = [pos2(101.0, 60.0), pos2(203.0, 60.0), pos2(203.0, 241.0), pos2(101.0, 241.0)]; + let (mesh, slanted) = shapes(polyline(&r, true), VIEW, style, true); + assert!(slanted.is_empty()); + let q = quads(&mesh, ppp); + let w = style.width; + // Every white base is `w` thick, and every dash is one pixel row (or column) of it. + for &[x0, y0, x1, y1, black] in &q { + let across = (x1 - x0).min(y1 - y0); + assert_eq!(across, if black == 1 { 1 } else { w }, "@{ppp}x: {:?}", [x0, y0, x1, y1]); + } + // Dashes are 4·w long, but where the edge's ends cut them. + let dashes: Vec = q.iter().filter(|q| q[4] == 1).map(|&[x0, y0, x1, y1, _]| (x1 - x0).max(y1 - y0)).collect(); + assert!(dashes.iter().all(|&n| n <= 4 * w) && dashes.iter().filter(|&&n| n == 4 * w).count() * 2 > dashes.len(), "@{ppp}x: {dashes:?}"); + } + } + + /// A 2 px line's rows are shifted 2 px against each other, so a dash ends on a slant (as in + /// Photoshop: the row nearer the bottom or right leads). + #[test] + fn a_thick_line_ends_its_dashes_on_a_slant() { + let style = Style::new(2.0, 0.0); + let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(90.0, 10.0)], false), VIEW, style, true); + let black = black_pixels(&quads(&mesh, 2.0)); + // Device row 19 and 20 straddle the edge at y = 20 px. + let row = |y: i64| -> Vec { (30..170).filter(|&x| black.contains(&(x, y))).collect() }; + let (upper, lower) = (row(19), row(20)); + assert_eq!(upper.len(), lower.len()); + let shifted: Vec = upper.iter().map(|x| x - 2).filter(|x| (30..168).contains(x)).collect(); + assert!(shifted.iter().all(|x| lower.contains(x)), "the lower row leads by 2 px: {upper:?} / {lower:?}"); + // The vertical edges too: the right column leads (starts higher) by 2 px. + let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(10.0, 90.0)], false), VIEW, style, true); + let black = black_pixels(&quads(&mesh, 2.0)); + let col = |x: i64| -> Vec { (30..170).filter(|&y| black.contains(&(x, y))).collect() }; + let (left, right) = (col(19), col(20)); + let shifted: Vec = left.iter().map(|y| y - 2).filter(|y| (30..168).contains(y)).collect(); + assert!(shifted.iter().all(|y| right.contains(y)), "{left:?} / {right:?}"); + } + + /// At 100 % the pattern is the classic one: black where `(x + y + phase) mod 8 < 4`, so + /// dashes line up across a corner and march with time. + #[test] + fn the_ants_march_and_line_up_at_corners() { + let at = |time: f64| { + let (mesh, _) = shapes(polyline(&[pos2(10.0, 10.0), pos2(50.0, 10.0), pos2(50.0, 50.0)], false), VIEW, Style::new(1.0, time), true); + black_pixels(&quads(&mesh, 1.0)) + }; + let still = at(0.0); + for x in 10..=50 { + assert_eq!(still.contains(&(x, 10)), (x + 10).rem_euclid(8) < 4, "top edge at {x}"); + } + for y in 10..=50 { + assert_eq!(still.contains(&(50, y)), (50 + y).rem_euclid(8) < 4, "right edge at {y}"); + } + assert_ne!(still, at(0.25), "the ants march"); + assert_eq!(still, at(0.8), "one period later they are back"); + } + + #[test] + fn a_long_edge_costs_only_its_visible_part() { + let (mesh, _) = shapes(polyline(&[pos2(-1e7, 100.0), pos2(1e7, 100.0)], false), VIEW, Style::new(1.0, 0.0), true); + // 800 px in view: ~100 dashes, not 2.5 million. + assert!(mesh.vertices.len() < 4 * 200, "{} vertices", mesh.vertices.len()); + } + + #[test] + fn slanted_lines_are_antialiased_strokes_as_thick_as_the_straight_ones() { + let style = Style::new(1.5, 0.0); + let (mesh, slanted) = shapes(polyline(&[pos2(10.0, 10.0), pos2(80.0, 50.0)], false), VIEW, style, false); + assert!(mesh.is_empty()); + assert!(slanted.len() > 2); + for s in &slanted { + let Shape::LineSegment { stroke, .. } = s else { panic!("{s:?}") }; + assert!((stroke.width * 1.5 - 2.0).abs() < 1e-4, "2 device px: {}", stroke.width); + } + let colours: Vec = slanted.iter().filter_map(|s| if let Shape::LineSegment { stroke, .. } = s { Some(stroke.color) } else { None }).collect(); + assert!(colours.contains(&Color32::WHITE) && colours.contains(&Color32::BLACK)); + } + + #[test] + fn hostile_points_draw_nothing_and_never_panic() { + for (a, b) in [ + (pos2(f32::NAN, 0.0), pos2(10.0, 10.0)), + (pos2(f32::INFINITY, 0.0), pos2(10.0, 10.0)), + (pos2(5.0, 5.0), pos2(5.0, 5.0)), + (pos2(-1e30, -1e30), pos2(-1e30, 1e30)), + (pos2(1e30, 5.0), pos2(-1e30, 5.0)), + ] { + for snap in [true, false] { + let _ = shapes([(a, b)].into_iter(), VIEW, Style::new(1.25, f64::NAN), snap); + } + } + assert_eq!(polyline(&[], true).count(), 0); + assert_eq!(polyline(&[pos2(0.0, 0.0)], true).count(), 0); + assert_eq!(polyline(&[pos2(0.0, 0.0), pos2(1.0, 0.0)], true).count(), 1); + assert_eq!(polyline(&[pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0)], true).count(), 3); + } +} diff --git a/crates/ui-egui/src/apply_color.rs b/crates/ui-egui/src/apply_color.rs new file mode 100644 index 0000000000..28d76113a4 --- /dev/null +++ b/crates/ui-egui/src/apply_color.rs @@ -0,0 +1,156 @@ +//! A new foreground colour recolours the selected objects: the Color panel, the Swatches panel and +//! the Color Picker set the foreground, and depending on the tool in hand it also goes to what is +//! selected: +//! +//! * characters selected while editing type: those characters (`type_tool::foreground_changed`); +//! * a Type tool, not editing: the colour of every selected type layer (`type.setStyle`); +//! * a shape tool, a Pen or a path selection tool: the fill of every selected shape layer +//! (`shape.edit` with `fill`); +//! * any other tool (the Move tool, the brushes, the pixel selection tools…): nothing, only the +//! foreground. +//! +//! A click on a swatch is broader, as in Photoshop: whatever the tool, it recolours every selected +//! type layer and the fill of every selected shape layer (#9238; Photoshop 2025 measured with the +//! Move and Brush tools), while the Color panel keeps to the rules above. +//! +//! One pick is one history step for all the layers it recolours; a drag in the Color panel's field +//! or sliders is one step too. + +use egui::{Context, Id}; +use photocraft_doc::{Color, Fill, LayerContent}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn hex(c: [f32; 4]) -> String { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2])) +} + +/// Does a new foreground recolour selected shape layers with `tool`? +fn recolours_shapes(tool: Tool) -> bool { + crate::vector_ui::is_shape_tool(tool) + || matches!( + tool, + Tool::PathSelection + | Tool::DirectSelection + | Tool::Pen + | Tool::FreeformPen + | Tool::CurvaturePen + | Tool::AddAnchorPoint + | Tool::DeleteAnchorPoint + | Tool::ConvertPoint + ) +} + +/// The gesture a Color panel pick belongs to: the press while the pointer is held (a drag in its +/// field or sliders), else the field being typed into (its value changes with every keystroke); +/// `None` for a click. Every pick of one gesture joins one history step, and the next press or +/// focused field starts another. +pub fn gesture(ctx: &Context) -> Option { + if let Some(t) = ctx.input(|i| if i.pointer.any_down() { i.pointer.press_start_time() } else { None }) { + return Some(format!("press-{}", t.to_bits())); + } + // A click (reported on its release) is a step of its own, even while a field keeps the focus. + if ctx.input(|i| i.pointer.any_released()) { + return None; + } + // The focused field; on the frame Enter commits a typed sum (`2*64`) it has just let go, so + // the field focused last frame. + let (last, n) = ctx.data(|d| d.get_temp::<(Option, u64)>(focus_key())).unwrap_or_default(); + ctx.memory(|m| m.focused()).or(last).map(|id| format!("field-{}-{n}", id.value())) +} + +fn focus_key() -> Id { + Id::unique("apply-color-focus") +} + +/// Counts the changes of keyboard focus, so two values typed into one field, each after +/// focusing it, are two history steps (see [`gesture`]). Called every frame by the Color panel. +pub fn track_focus(ctx: &Context) { + let focused = ctx.memory(|m| m.focused()); + ctx.data_mut(|d| { + let (last, n) = d.get_temp::<(Option, u64)>(focus_key()).unwrap_or_default(); + if last != focused { + d.insert_temp(focus_key(), (focused, n.wrapping_add(1))); + } + }); +} + +/// The foreground colour just changed through the Color panel, the Swatches panel or the Color +/// Picker: recolour what is selected, as the tool in hand says (see the module docs). +/// Picks of one `gesture` (a Color panel drag or typed value, see [`gesture`]) share one history +/// step; `None` (a swatch, the Color Picker, a click) is a step of its own. +pub fn foreground_changed(app: &mut PhotocraftApp, gesture: Option) { + recolour(app, gesture, false); +} + +/// A swatch just set the foreground: recolour the selected characters while editing type, else +/// every selected type layer and shape fill, whatever the tool (see the module docs). +pub fn swatch_picked(app: &mut PhotocraftApp) { + recolour(app, None, true); +} + +fn recolour(app: &mut PhotocraftApp, gesture: Option, any_tool: bool) { + // Editing type: the selected characters, in the editing session's step; nothing else. + if app.ui.text_edit.is_some() { + crate::type_tool::foreground_changed(app); + return; + } + let tool = app.ui.tool; + // What the tool in hand recolours: type layers or shape layers (a swatch: both). + let (do_texts, do_shapes) = match tool { + _ if any_tool => (true, true), + t if t.is_type() && !t.is_type_mask() => (true, false), + t if recolours_shapes(t) => (false, true), + _ => return, + }; + let Some(st) = app.session.active() else { return }; + let fg = app.session.tools.foreground; + let (texts, shapes): (Vec, Vec<(u64, f32)>) = { + let selected: Vec<_> = st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).collect(); + let texts = selected.iter().filter(|l| matches!(l.content, LayerContent::Text(_))).map(|l| l.id.0).collect(); + // A shape keeps its fill's own opacity and takes the float colour as it is (#5568); + // locked shapes and ones already filled with it are left alone. + let shapes = selected + .iter() + .filter_map(|l| { + let LayerContent::Shape(sh) = &l.content else { return None }; + let locks = st.doc.effective_locks(l.id); + if locks.all || locks.pixels { + return None; + } + let alpha = match &sh.fill { + Some(Fill::Solid(c)) => c.alpha, + _ => 1.0, + }; + (sh.fill != Some(Fill::Solid(Color::rgba(fg[0], fg[1], fg[2], alpha)))).then_some((l.id.0, alpha)) + }) + .collect(); + (texts, shapes) + }; + let texts = if do_texts { texts } else { Vec::new() }; + let shapes = if do_shapes { shapes } else { Vec::new() }; + if texts.is_empty() && shapes.is_empty() { + return; + } + let key = match gesture { + Some(g) => format!("apply-color-{g}"), + None => format!("apply-color-{}", app.ui.alloc_id()), + }; + let result = (|| { + if !texts.is_empty() { + app.run("type.setStyle", json!({"layers": texts, "color": hex(fg), "coalesce": key}))?; + } + shapes.iter().try_for_each(|(id, alpha)| app.run("shape.edit", json!({"layer": id, "fill": [fg[0], fg[1], fg[2], alpha], "coalesce": key})).map(|_| ())) + })(); + if let Err(e) = result { + app.ui.status = e; + app.ui.status_error = true; + } +} + +#[cfg(test)] +#[path = "apply_color_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/apply_color_tests.rs b/crates/ui-egui/src/apply_color_tests.rs new file mode 100644 index 0000000000..4a37fa6498 --- /dev/null +++ b/crates/ui-egui/src/apply_color_tests.rs @@ -0,0 +1,175 @@ +//! A new foreground colour recolours the selected type or shape layers, as the tool in hand says. + +use photocraft_doc::{Fill, LayerContent, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// Two type layers and two shape layers (black, the default colours), all selected. +fn app(tool: Tool) -> (PhotocraftApp, Vec, Vec) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.sync_views(); + app.run("tools.setColors", json!({"foreground": "#000000"})).unwrap(); + let mut texts = Vec::new(); + for y in [40, 90] { + texts.push(LayerId(app.run("type.create", json!({"x": 20, "y": y, "text": "Hello", "size": 20})).unwrap()["layer"].as_u64().unwrap())); + } + let mut shapes = Vec::new(); + for x in [150, 220] { + app.run("shape.create", json!({"kind": "rect", "rect": [x, 20, 50, 50], "fill": "#000000"})).unwrap(); + shapes.push(app.session.active().unwrap().active_layer.unwrap()); + } + for id in texts.iter().chain(&shapes) { + app.run("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); + } + app.ui.tool = tool; + (app, texts, shapes) +} + +fn text_rgb(app: &PhotocraftApp, id: LayerId) -> [f32; 3] { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Text(t) => { + let c = t.char_runs().into_iter().next().map_or(t.color, |r| r.style.color); + [c.c[0], c.c[1], c.c[2]] + } + _ => panic!("type layer"), + } +} + +fn fill_rgb(app: &PhotocraftApp, id: LayerId) -> Option<[f32; 3]> { + match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => match &sh.fill { + Some(Fill::Solid(c)) => Some([c.c[0], c.c[1], c.c[2]]), + _ => None, + }, + _ => panic!("shape layer"), + } +} + +fn red(c: [f32; 3]) -> bool { + (c[0] - 1.0).abs() < 1e-3 && c[1].abs() < 1e-3 && c[2].abs() < 1e-3 +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() +} + +fn pick(app: &mut PhotocraftApp, c: [f32; 4], gesture: Option<&str>) { + app.session.tools.foreground = c; + super::foreground_changed(app, gesture.map(str::to_string)); +} + +#[test] +fn a_type_tool_recolours_every_selected_type_layer_in_one_step() { + for tool in [Tool::Type, Tool::VerticalType] { + let (mut app, texts, shapes) = app(tool); + let n = steps(&app); + pick(&mut app, RED, None); + assert!(texts.iter().all(|id| red(text_rgb(&app, *id))), "{tool:?}"); + assert!(shapes.iter().all(|id| !red(fill_rgb(&app, *id).unwrap())), "{tool:?}: shapes keep their fill"); + assert_eq!(steps(&app), n + 1, "one history step"); + assert!(app.session.undo()); + assert!(texts.iter().all(|id| !red(text_rgb(&app, *id)))); + } +} + +#[test] +fn shape_and_path_tools_recolour_every_selected_shape_fill_in_one_step() { + for tool in [Tool::Rectangle, Tool::Polygon, Tool::PathSelection, Tool::DirectSelection, Tool::Pen, Tool::CurvaturePen] { + let (mut app, texts, shapes) = app(tool); + let n = steps(&app); + pick(&mut app, RED, None); + assert!(shapes.iter().all(|id| fill_rgb(&app, *id).is_some_and(red)), "{tool:?}"); + assert!(texts.iter().all(|id| !red(text_rgb(&app, *id))), "{tool:?}: type keeps its colour"); + assert_eq!(steps(&app), n + 1, "{tool:?}: one history step for both layers"); + } +} + +#[test] +fn other_tools_only_change_the_foreground() { + for tool in [Tool::Move, Tool::Brush, Tool::RectMarquee, Tool::Eyedropper, Tool::HorizontalTypeMask] { + let (mut app, texts, shapes) = app(tool); + let n = steps(&app); + pick(&mut app, RED, None); + assert_eq!(app.session.tools.foreground, RED); + assert!(texts.iter().all(|id| !red(text_rgb(&app, *id))) && shapes.iter().all(|id| !red(fill_rgb(&app, *id).unwrap())), "{tool:?}"); + assert_eq!(steps(&app), n, "{tool:?}: nothing recorded"); + } +} + +#[test] +fn a_color_panel_drag_is_one_history_step_and_a_new_pick_another() { + let (mut app, _, shapes) = app(Tool::Rectangle); + let n = steps(&app); + pick(&mut app, [0.2, 0.0, 0.0, 1.0], None); + assert_eq!(steps(&app), n + 1, "a click or a swatch is one step"); + // One drag: every pick of one press. + pick(&mut app, [0.4, 0.0, 0.0, 1.0], Some("press-1")); + pick(&mut app, [0.6, 0.0, 0.0, 1.0], Some("press-1")); + pick(&mut app, RED, Some("press-1")); + assert!(fill_rgb(&app, shapes[0]).is_some_and(red)); + assert_eq!(steps(&app), n + 2, "the drag is one step, not joined to the click before it"); + pick(&mut app, [0.0, 0.0, 1.0, 1.0], Some("press-2")); + assert_eq!(steps(&app), n + 3, "the next drag is another"); + pick(&mut app, [0.0, 1.0, 0.0, 1.0], None); + assert_eq!(steps(&app), n + 4, "a new pick is another"); +} + +#[test] +fn a_swatch_and_the_color_picker_go_through_it() { + let (mut app, _, shapes) = app(Tool::Rectangle); + let same = |app: &PhotocraftApp, id: LayerId| { + let f = app.session.tools.foreground; + fill_rgb(app, id).is_some_and(|c| (0..3).all(|i| (c[i] - f[i]).abs() < 1e-3)) + }; + // A swatch from the default library (the first one that isn't black). + let names = app.run("swatches.list", json!({})).unwrap(); + let name = names["groups"] + .as_array() + .into_iter() + .flatten() + .flat_map(|g| g["swatches"].as_array().cloned().unwrap_or_default()) + .find(|w| w["hex"].as_str().is_some_and(|h| h != "#000000")) + .and_then(|w| w["name"].as_str().map(str::to_string)) + .expect("a default swatch"); + crate::swatches_ui::use_swatch(&mut app, &name, false); + assert_ne!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert!(shapes.iter().all(|id| same(&app, *id)), "the swatch recoloured the shapes"); + // The Color Picker's OK for the foreground. + let mut f = serde_json::Map::new(); + f.insert("__colorPicker".into(), json!("foreground")); + f.insert("color".into(), json!("#0000ff")); + crate::color_picker_ui::confirm(&mut app, &f).unwrap(); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 1.0, 1.0]); + assert!(shapes.iter().all(|id| same(&app, *id)), "the picker recoloured the shapes"); +} + +/// #9238: a swatch recolours the selected type layers and shape fills whatever the tool, as in +/// Photoshop (measured with the Move and Brush tools); the Color panel with the same tools only +/// sets the foreground (`other_tools_only_change_the_foreground`). +#[test] +fn a_swatch_recolours_selected_type_and_shapes_with_any_tool() { + for tool in [Tool::Move, Tool::Brush, Tool::RectMarquee, Tool::Type, Tool::Rectangle] { + let (mut app, texts, shapes) = app(tool); + let n = steps(&app); + let names = app.run("swatches.list", json!({})).unwrap(); + let (name, hex) = names["groups"] + .as_array() + .into_iter() + .flatten() + .flat_map(|g| g["swatches"].as_array().cloned().unwrap_or_default()) + .find(|w| w["hex"].as_str().is_some_and(|h| h != "#000000")) + .and_then(|w| Some((w["name"].as_str()?.to_string(), w["hex"].as_str()?.to_string()))) + .expect("a default swatch"); + crate::swatches_ui::use_swatch(&mut app, &name, false); + let f = app.session.tools.foreground; + let same = |c: [f32; 3]| (0..3).all(|i| (c[i] - f[i]).abs() < 2e-3); + assert!(texts.iter().all(|id| same(text_rgb(&app, *id))), "{tool:?}: the swatch {hex} recoloured the type layers"); + assert!(shapes.iter().all(|id| fill_rgb(&app, *id).is_some_and(same)), "{tool:?}: and the shape fills"); + assert_eq!(steps(&app), n + 1, "{tool:?}: one history step"); + } +} diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 7f8ee838c6..81dd67bd02 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -304,14 +304,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo let mut preset = preset.unwrap_or_default(); let mut opts: Vec<(String, &str)> = vec![(String::new(), tl!("Custom"))]; opts.extend(photocraft_engine::artboard_cmds::PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n))); - if crate::widgets::dropdown(ui, "artboard-tool-preset", &mut preset, &opts, 150.0) && !preset.is_empty() { + if crate::widgets::dropdown(ui, "artboard-tool-preset", tl!("Preset"), &mut preset, &opts, 150.0) && !preset.is_empty() { edit = Some(json!({"layer": id.0, "preset": preset})); } let (mut w, mut h) = ((r[2] - r[0]) as f32, (r[3] - r[1]) as f32); ui.label(egui::RichText::new(tl!("W")).color(t.text_dim)); - let cw = crate::widgets::value_field(ui, &mut w, 1.0..=300_000.0, "px", 64.0).changed(); + let cw = crate::widgets::value_field(ui, &mut w, 1.0..=300_000.0, "px", 64.0).accessible_name(tl!("Width")).changed(); ui.label(egui::RichText::new(tl!("H")).color(t.text_dim)); - let ch = crate::widgets::value_field(ui, &mut h, 1.0..=300_000.0, "px", 64.0).changed(); + let ch = crate::widgets::value_field(ui, &mut h, 1.0..=300_000.0, "px", 64.0).accessible_name(tl!("Height")).changed(); if cw || ch { edit = Some(json!({"layer": id.0, "width": w.round(), "height": h.round(), "coalesce": format!("artboard-tool-wh-{}", id.0)})); } diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index eac9fe77aa..3aa6e9c0c2 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -121,6 +121,8 @@ pub fn fit_artboard(app: &mut PhotocraftApp) -> Result { // overflows an i32 sum (#981). v.center = [((f64::from(b.x0) + f64::from(b.x1)) / 2.0) as f32, ((f64::from(b.y0) + f64::from(b.y1)) / 2.0) as f32]; v.fit_pending = false; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); + v.zoom_anchor = None; Ok(json!({"zoom": v.zoom, "artboard": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1]})) } @@ -139,27 +141,30 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { opts.extend(photocraft_engine::artboard_cmds::PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n))); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Size")).color(t.text_dim).size(12.0)); - if crate::widgets::dropdown(ui, &key("preset"), &mut preset, &opts, 150.0) && !preset.is_empty() { + if crate::widgets::dropdown(ui, &key("preset"), tl!("Preset"), &mut preset, &opts, 150.0) && !preset.is_empty() { edit = Some(json!({"layer": layer.id.0, "preset": preset})); } }); - let num = |ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive| -> bool { + // W/H use a short visible label but the full word as the accessible name (a screen reader + // says "Width", not "W"); X/Y already name their axis, so their name comes from the call site + // too. + let num = |ui: &mut egui::Ui, label: &str, name: &str, v: &mut f32, range: std::ops::RangeInclusive| -> bool { ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim).size(12.0)); - crate::widgets::value_field(ui, v, range, "px", 64.0).changed() + crate::widgets::value_field(ui, v, range, "px", 64.0).accessible_name(name).changed() }; let r = a.rect; ui.horizontal(|ui| { let (mut w, mut h) = (r.width() as f32, r.height() as f32); - let cw = num(ui, "W", &mut w, 1.0..=300_000.0); - let ch = num(ui, "H", &mut h, 1.0..=300_000.0); + let cw = num(ui, "W", tl!("Width"), &mut w, 1.0..=300_000.0); + let ch = num(ui, "H", tl!("Height"), &mut h, 1.0..=300_000.0); if cw || ch { edit = Some(json!({"layer": layer.id.0, "width": w.round(), "height": h.round(), "coalesce": key("wh")})); } }); ui.horizontal(|ui| { let (mut x, mut y) = (r.x0 as f32, r.y0 as f32); - let cx = num(ui, "X", &mut x, -300_000.0..=300_000.0); - let cy = num(ui, "Y", &mut y, -300_000.0..=300_000.0); + let cx = num(ui, "X", tl!("X"), &mut x, -300_000.0..=300_000.0); + let cy = num(ui, "Y", tl!("Y"), &mut y, -300_000.0..=300_000.0); if cx || cy { edit = Some(json!({"layer": layer.id.0, "x": x.round(), "y": y.round(), "coalesce": key("xy")})); } @@ -176,7 +181,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { ("transparent".to_string(), tl!("Transparent")), ("custom".to_string(), tl!("Other…")), ]; - if crate::widgets::dropdown(ui, &key("bg"), &mut bg, &opts, 120.0) { + if crate::widgets::dropdown(ui, &key("bg"), tl!("Background"), &mut bg, &opts, 120.0) { let mut params = json!({"layer": layer.id.0, "background": bg}); if bg == "custom" { let hex = match a.background { @@ -442,4 +447,23 @@ mod tests { app.ui.layer_comp_selected = Some(999); assert_eq!(crate::comps_ui::target(&app), Some(b)); } + + #[test] + fn property_fields_are_named_for_their_axis() { + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + + let (mut app, _) = app(); + app.run("layer.new.artboard", json!({"rect": [0, 0, 40, 50]})).unwrap(); + let id = app.session.active().unwrap().doc.artboards()[0].0; + let layer = app.session.active().unwrap().doc.layer(id).unwrap().clone(); + let mut h = Harness::builder() + .with_size(egui::vec2(520.0, 420.0)) + .with_context_setup(|ctx| PhotocraftApp::setup_context(ctx, crate::theme::ThemeKind::ALL[0])) + .build_ui_state(move |ui, app: &mut PhotocraftApp| properties(app, ui, &layer), app); + h.run_steps(2); + let labels: Vec = h.query_all_by_role(Role::SpinButton).filter_map(|n| n.accesskit_node().label()).collect(); + assert_eq!(labels, ["Width", "Height", "X", "Y"].map(String::from).to_vec(), "the fields announce their own axis in layout order"); + } } diff --git a/crates/ui-egui/src/background_redraw.rs b/crates/ui-egui/src/background_redraw.rs index 31fdb649a7..8b13c01eab 100644 --- a/crates/ui-egui/src/background_redraw.rs +++ b/crates/ui-egui/src/background_redraw.rs @@ -16,6 +16,8 @@ //! uploads those blocks instead of compositing every layer again. use std::collections::VecDeque; +#[cfg(test)] +use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, TryRecvError}; use std::sync::{Arc, Weak}; @@ -43,6 +45,55 @@ const KEEP: usize = 2; /// [`GpuCanvas::refresh`] kind of a redraw served from a [`Kept`] composite. pub(crate) const KEPT: &str = "kept"; +/// Tests hold the redraw worker before it draws a batch, so an edit lands while blocks are still +/// to come. A real run never waits: a fast machine draws a test document before its next frame, +/// leaving nothing drawn "before" the edit. +#[cfg(test)] +static HOLD: AtomicBool = AtomicBool::new(false); + +/// Identity of the next spawned redraw worker: lets a test tell the worker it watched from a +/// replacement that [`restart`] spawns after an edit. +#[cfg(test)] +static NEXT_WORKER_GENERATION: AtomicU64 = AtomicU64::new(1); + +/// Hold the redraw worker until the guard drops. +#[cfg(test)] +pub(crate) fn hold_redraws() -> HoldRedraws { + HOLD.store(true, Ordering::SeqCst); + HoldRedraws +} + +/// Wait until worker `generation` of document `doc`'s [`Redraw`] has reached its pre-batch +/// [`HOLD`]. A replacement worker has another generation, so an edit only lands while the worker +/// the test watched still has its blocks to draw. Returns `false` after `timeout`. +#[cfg(test)] +pub(crate) fn wait_for_held_redraw(app: &PhotocraftApp, doc: DocId, generation: u64, timeout: std::time::Duration) -> bool { + let deadline = std::time::Instant::now() + timeout; + loop { + if let Some(r) = app.redraws.get(&doc) + && r.worker_generation == generation + && r.reached_hold.load(Ordering::SeqCst) + { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} + +/// Releases [`hold_redraws`], including while a test unwinds. +#[cfg(test)] +pub(crate) struct HoldRedraws; + +#[cfg(test)] +impl Drop for HoldRedraws { + fn drop(&mut self) { + HOLD.store(false, Ordering::SeqCst); + } +} + /// One finished block, as texels of the canvas texture. struct Piece { block: usize, @@ -63,15 +114,31 @@ pub(crate) struct Redraw { /// The state the blocks are drawn from, and what the texels depend on besides it. doc: Arc, display: u64, + text_gamma: u32, format: eframe::wgpu::TextureFormat, /// Uploaded blocks of `doc` kept for [`Kept`] (empty once over `cap`), and their bytes. collected: Vec>>, collected_bytes: u64, /// Bytes the kept composites may hold: what Memory Usage leaves beside the document. cap: u64, + /// Set when this worker first reaches the test [`HOLD`], and this worker's generation. + #[cfg(test)] + reached_hold: Arc, + #[cfg(test)] + worker_generation: u64, } impl Redraw { + /// The text gamma changed while it runs (even if back since): its blocks may mix both, so + /// none of them are kept. Checked every frame for every redraw, shown or not ([`retain`]); a + /// change and back between two frames (one batch of agent commands) isn't seen. + fn forget_if_gamma_changed(&mut self, gamma: u32) { + if self.text_gamma != gamma { + self.collected = Vec::new(); + self.collected_bytes = 0; + } + } + /// Share of the document drawn, 0..=1. pub fn fraction(&self) -> f32 { if self.blocks.is_empty() { 1.0 } else { 1.0 - self.left as f32 / self.blocks.len() as f32 } @@ -91,6 +158,7 @@ pub(crate) struct Kept { doc: Weak, id: DocId, display: u64, + text_gamma: u32, format: eframe::wgpu::TextureFormat, blocks: Vec, texels: Vec>, @@ -101,9 +169,11 @@ impl Kept { /// Whether these are the texels a redraw of `doc` would draw. The key is the whole document /// (layers and their pixels, visibility, opacity, blending, masks, adjustments and everything /// else it holds), so a stale composite can't come back; Arc-shared tiles compare without - /// reading their pixels. - fn matches(&self, doc: &Arc, display: u64, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> bool { + /// reading their pixels. Besides the document, the display transform and the text gamma + /// ([`text_gamma`]) decide the texels. + fn matches(&self, doc: &Arc, display: u64, text_gamma: u32, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> bool { self.display == display + && self.text_gamma == text_gamma && self.format == format && self.blocks == blocks && self.doc.upgrade().is_some_and(|kept| Arc::ptr_eq(&kept, doc) || *kept == **doc) @@ -114,12 +184,14 @@ impl Kept { #[derive(Default)] pub(crate) struct KeptComposites { entries: VecDeque, + /// The entries' documents and their revisions, and Memory Usage, when the cap was last taken. + capped: (Vec<(DocId, u64)>, u64), } impl KeptComposites { /// The kept redraw of exactly this state, now the newest. - fn find(&mut self, doc: &Arc, display: u64, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> Option<&Kept> { - let i = self.entries.iter().position(|k| k.matches(doc, display, format, blocks))?; + fn find(&mut self, doc: &Arc, display: u64, text_gamma: u32, format: eframe::wgpu::TextureFormat, blocks: &[Rect]) -> Option<&Kept> { + let i = self.entries.iter().position(|k| k.matches(doc, display, text_gamma, format, blocks))?; let k = self.entries.remove(i)?; self.entries.push_front(k); self.entries.front() @@ -128,7 +200,9 @@ impl KeptComposites { /// Keep `k`, the redraw of `doc` (in place of an entry of the same state), within `cap` bytes /// in all. fn store(&mut self, k: Kept, doc: &Arc, cap: u64) { - self.entries.retain(|e| !e.matches(doc, k.display, k.format, &k.blocks)); + // Another list: [`retain`] takes the cap over all its documents again. + self.capped = Default::default(); + self.entries.retain(|e| !e.matches(doc, k.display, k.text_gamma, k.format, &k.blocks)); self.entries.push_front(k); self.trim(cap); } @@ -151,6 +225,13 @@ impl KeptComposites { } } +/// Color Settings › "Blend Text Colors Using Gamma": type layers composite with it, but it is an +/// application setting, not part of the document. Read from the session's settings, which the +/// engine keeps in step with the process-wide value compose reads (`psblend::set_text_gamma`). +fn text_gamma(app: &PhotocraftApp) -> u32 { + app.session.color.settings.blend_text_gamma.to_bits() +} + /// Bytes kept composites of document `doc` may hold: what Memory Usage leaves beside its pixels /// and History. It shrinks as the document grows, which drops them under memory pressure. fn keep_cap(app: &PhotocraftApp, doc: DocId) -> u64 { @@ -208,13 +289,14 @@ pub(crate) fn start( ) { let all = blocks(doc.bounds()); let display_key = display.as_deref().map_or(0, |d| d.texture_key); + let gamma = text_gamma(app); // Taken again when a running redraw continues: the edit (or Memory Usage) may have changed it. let cap = keep_cap(app, doc.id); app.kept.trim(cap); let (mut pending, since, fallback, mut keep) = match app.redraws.remove(&doc.id) { Some(mut old) if old.blocks == all => { // Blocks drawn from another state aren't part of this one's composite. - let same = old.display == display_key && (Arc::ptr_eq(&old.doc, &doc) || *old.doc == *doc); + let same = old.display == display_key && old.text_gamma == gamma && (Arc::ptr_eq(&old.doc, &doc) || *old.doc == *doc); let (collected, bytes) = if same && old.collected_bytes <= cap { (std::mem::take(&mut old.collected), old.collected_bytes) } else { (vec![None; all.len()], 0) }; (std::mem::take(&mut old.pending), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.take()), (collected, bytes, cap)) @@ -229,7 +311,7 @@ pub(crate) fn start( } let Some(format) = gpu.prepare_texture(doc.id.0, &doc) else { return }; // This exact state was drawn a moment ago: upload its blocks rather than composite them. - if let Some(k) = app.kept.find(&doc, display_key, format, &all) { + if let Some(k) = app.kept.find(&doc, display_key, gamma, format, &all) { let t0 = crate::gpu_canvas::now_ms(); let mut px = 0u64; for (i, p) in pending.iter_mut().enumerate() { @@ -246,7 +328,7 @@ pub(crate) fn start( } keep = (vec![None; all.len()], 0, keep.2); } - spawn(app, ctx, Spawn { doc, display, display_key, format, visible, blocks: all, pending, since, fallback, keep }); + spawn(app, ctx, Spawn { doc, display, display_key, text_gamma: gamma, format, visible, blocks: all, pending, since, fallback, keep }); } /// After an edit drew its own damage: continue document `doc.id`'s redraw, if one is running, on @@ -261,7 +343,9 @@ pub(crate) fn restart(app: &mut PhotocraftApp, ctx: &egui::Context, gpu: &GpuCan /// lands, the redraw is recorded like the synchronous refresh it replaces (and explained once if /// it was slow). pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) { + let gamma = text_gamma(app); let Some(r) = app.redraws.get_mut(&doc) else { return }; + r.forget_if_gamma_changed(gamma); loop { match r.rx.try_recv() { Ok(p) => { @@ -288,6 +372,7 @@ pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) { doc: Arc::downgrade(&r.doc), id: r.doc.id, display: r.display, + text_gamma: r.text_gamma, format: r.format, blocks: r.blocks.clone(), texels, @@ -335,6 +420,26 @@ pub(crate) fn retain(app: &mut PhotocraftApp) { let docs = app.session.documents(); app.redraws.retain(|id, _| docs.iter().any(|st| st.doc.id == *id)); app.kept.retain(|id| docs.iter().any(|st| st.doc.id == id)); + let gamma = text_gamma(app); + for r in app.redraws.values_mut() { + r.forget_if_gamma_changed(gamma); + } + // Not only when the next redraw starts: states History dropped (purged or trimmed) go at once, + // and the cap is taken again when a kept document (its pixels and History) or Memory Usage + // changed. + app.kept.entries.retain(|k| k.doc.strong_count() > 0); + if app.kept.entries.is_empty() { + return; + } + let docs = app.session.documents(); + let mut ids: Vec<(DocId, u64)> = app.kept.entries.iter().filter_map(|k| docs.iter().find(|st| st.doc.id == k.id).map(|st| (k.id, st.revision))).collect(); + ids.dedup(); + let capped = (ids, app.session.prefs().performance.history_budget_bytes() as u64); + if capped != app.kept.capped { + let cap = capped.0.iter().map(|&(id, _)| keep_cap(app, id)).min().unwrap_or(0); + app.kept.capped = capped; + app.kept.trim(cap); + } } /// What [`spawn`] starts a redraw with. @@ -342,6 +447,7 @@ struct Spawn { doc: Arc, display: Option>, display_key: u64, + text_gamma: u32, format: eframe::wgpu::TextureFormat, visible: Rect, blocks: Vec, @@ -354,19 +460,41 @@ struct Spawn { } fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) { - let Spawn { doc, display, display_key, format, visible, blocks, pending, since: (started_ms, drawn), fallback, keep: (collected, collected_bytes, cap) } = - s; + let Spawn { + doc, + display, + display_key, + text_gamma, + format, + visible, + blocks, + pending, + since: (started_ms, drawn), + fallback, + keep: (collected, collected_bytes, cap), + } = s; let id = doc.id; let kept_doc = doc.clone(); let todo = order(&blocks, (0..blocks.len()).filter(|&i| pending[i]), visible); let left = todo.len(); let (tx, rx) = std::sync::mpsc::sync_channel(BATCH); let stop = Arc::new(AtomicBool::new(false)); + #[cfg(test)] + let reached_hold = Arc::new(AtomicBool::new(false)); + #[cfg(test)] + let worker_generation = NEXT_WORKER_GENERATION.fetch_add(1, Ordering::SeqCst); + #[cfg(test)] + let worker_hold = reached_hold.clone(); let work = (blocks.clone(), stop.clone(), ctx.clone()); let spawned = std::thread::Builder::new().name("canvas-redraw".into()).spawn(move || { let (blocks, stop, ctx) = work; // A panic stops the redraw: the receiver sees the worker gone and drops it. - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| draw(&doc, display.as_deref(), format, &blocks, &todo, &stop, &tx, &ctx))); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + #[cfg(test)] + draw(&doc, display.as_deref(), format, &blocks, &todo, &stop, &tx, &ctx, &worker_hold); + #[cfg(not(test))] + draw(&doc, display.as_deref(), format, &blocks, &todo, &stop, &tx, &ctx); + })); ctx.request_repaint(); }); if let Err(e) = spawned { @@ -375,7 +503,27 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) { } app.redraws.insert( id, - Redraw { blocks, pending, left, drawn, rx, stop, started_ms, fallback, doc: kept_doc, display: display_key, format, collected, collected_bytes, cap }, + Redraw { + blocks, + pending, + left, + drawn, + rx, + stop, + started_ms, + fallback, + doc: kept_doc, + display: display_key, + text_gamma, + format, + collected, + collected_bytes, + cap, + #[cfg(test)] + reached_hold, + #[cfg(test)] + worker_generation, + }, ); } @@ -391,8 +539,17 @@ fn draw( stop: &AtomicBool, tx: &SyncSender, ctx: &egui::Context, + #[cfg(test)] reached_hold: &AtomicBool, ) { for batch in todo.chunks(BATCH) { + // A test may hold the worker here; a cancelled redraw must still exit, not wait for release. + #[cfg(test)] + while HOLD.load(Ordering::SeqCst) && !stop.load(Ordering::Relaxed) { + // This worker, identified by its own acknowledgement, reached the hold: a replacement + // spawned after the edit starts with another one and can't satisfy the test's wait. + reached_hold.store(true, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(1)); + } if stop.load(Ordering::Relaxed) { return; } diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index 0d8d465d5e..114a150eb1 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -39,6 +39,7 @@ fn kept(doc: &Arc, bytes: usize) -> Kept { doc: Arc::downgrade(doc), id: doc.id, display: 7, + text_gamma: 1, format: eframe::wgpu::TextureFormat::Rgba8Unorm, bytes: (bytes * all.len()) as u64, blocks: all, @@ -65,8 +66,8 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() { let mut cache = KeptComposites::default(); cache.store(kept(&a, 4), &a, u64::MAX); // The same state hits, also as an equal document that isn't the same Arc (a restore after a solo). - assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some()); - assert!(cache.find(&Arc::new((*a).clone()), 7, Rgba8Unorm, &all).is_some()); + assert!(cache.find(&a, 7, 1, Rgba8Unorm, &all).is_some()); + assert!(cache.find(&Arc::new((*a).clone()), 7, 1, Rgba8Unorm, &all).is_some()); // Any change to what the composite depends on misses. let hidden = changed(&|d| d.layers[0].visible = false); let faded = changed(&|d| d.layers[0].opacity = 0.5); @@ -77,18 +78,23 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() { } }); for (what, d) in [("visibility", &hidden), ("opacity", &faded), ("blend mode", &multiplied), ("pixels", &painted)] { - assert!(cache.find(d, 7, Rgba8Unorm, &all).is_none(), "{what} changed"); + assert!(cache.find(d, 7, 1, Rgba8Unorm, &all).is_none(), "{what} changed"); } - assert!(cache.find(&a, 8, Rgba8Unorm, &all).is_none(), "another display transform"); - assert!(cache.find(&a, 7, Rgba16Float, &all).is_none(), "another texture format"); - assert!(cache.find(&a, 7, Rgba8Unorm, &all[1..]).is_none(), "other blocks"); + assert!(cache.find(&a, 8, 1, Rgba8Unorm, &all).is_none(), "another display transform"); + assert!(cache.find(&a, 7, 2, Rgba8Unorm, &all).is_none(), "another text gamma"); + assert!(cache.find(&a, 7, 1, Rgba16Float, &all).is_none(), "another texture format"); + assert!(cache.find(&a, 7, 1, Rgba8Unorm, &all[1..]).is_none(), "other blocks"); // At most KEEP entries, the least recently used going first. cache.store(kept(&hidden, 4), &hidden, u64::MAX); - assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some()); + assert!(cache.find(&a, 7, 1, Rgba8Unorm, &all).is_some()); cache.store(kept(&faded, 4), &faded, u64::MAX); assert_eq!(cache.entries.len(), KEEP); - assert!(cache.find(&hidden, 7, Rgba8Unorm, &all).is_none(), "the least recently used went"); - assert!(cache.find(&a, 7, Rgba8Unorm, &all).is_some() && cache.find(&faded, 7, Rgba8Unorm, &all).is_some()); + assert!(cache.find(&hidden, 7, 1, Rgba8Unorm, &all).is_none(), "the least recently used went"); + assert!(cache.find(&a, 7, 1, Rgba8Unorm, &all).is_some() && cache.find(&faded, 7, 1, Rgba8Unorm, &all).is_some()); + // A stored entry makes the next frame take the cap again, over every kept document. + cache.capped = (vec![(a.id, 1)], 1); + cache.store(kept(&faded, 4), &faded, u64::MAX); + assert_eq!(cache.capped, Default::default()); // Storing a state again replaces its entry. cache.store(kept(&faded, 4), &faded, u64::MAX); assert_eq!(cache.entries.len(), 2); @@ -109,7 +115,7 @@ fn kept_composites_come_back_only_for_the_exact_state_and_stay_bounded() { cache.store(kept(&gone, 4), &gone, u64::MAX); let equal = Arc::new((*gone).clone()); drop(gone); - assert!(cache.find(&equal, 7, Rgba8Unorm, &all).is_none(), "no strong reference to the drawn state"); + assert!(cache.find(&equal, 7, 1, Rgba8Unorm, &all).is_none(), "no strong reference to the drawn state"); cache.trim(u64::MAX); assert!(cache.entries.is_empty()); } @@ -204,10 +210,20 @@ fn a_full_cpu_refresh_draws_in_the_background_with_the_synchronous_pixels() { fn an_edit_during_a_redraw_is_not_overwritten_by_blocks_drawn_before_it() { let _gpu = photocraft_testkit::gpu_lock(); let Some(mut g) = gpu_app() else { return }; - // 4×3 blocks: the worker can't hand them all over before the next frame. + // Hold the worker before its first batch and wait until this very worker acknowledges that + // hold: when the fill lands, all 12 blocks of this 4×3 document are still to draw by it. + // Without the hold a fast machine finishes them before the next frame, so the edit never + // races the worker and this test only checked a completed redraw; without the per-worker + // acknowledgement an edit could beat the worker to the hold, and its restarted replacement + // would satisfy the checks. + let held = hold_redraws(); let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072); g.harness.step(); - assert!(g.harness.state().redraws.contains_key(&doc)); + let generation = g.harness.state().redraws.get(&doc).expect("the frame started a redraw").worker_generation; + assert!( + wait_for_held_redraw(g.harness.state(), doc, generation, std::time::Duration::from_secs(10)), + "the redraw worker never reached the pre-batch hold; an edit would race an unstarted redraw" + ); // A corner, drawn last (farthest from the view's centre), changes on a layer of its own while // the redraw runs: a small edit, drawn at once. g.harness.state_mut().run("layer.new.layer", json!({})).expect("layer"); @@ -217,7 +233,12 @@ fn an_edit_during_a_redraw_is_not_overwritten_by_blocks_drawn_before_it() { g.harness.state_mut().run("edit.fill", json!({"color": "#22ee22"})).expect("fill"); g.harness.step(); assert_eq!(g.harness.state().perf.last_refresh, "rect", "the fill's damage was drawn synchronously"); - assert!(g.harness.state().redraws.contains_key(&doc), "the redraw goes on"); + // Still running with blocks to draw, and drawing the edited document, not the state before it. + let current = g.harness.state().session.active().expect("document").doc.clone(); + let r = g.harness.state().redraws.get(&doc).expect("the redraw goes on"); + assert!(r.pending.iter().any(|p| *p), "blocks are still to draw"); + assert!(Arc::ptr_eq(&r.doc, ¤t) || *r.doc == *current, "the blocks left draw the edit"); + drop(held); settle(&mut g); assert_matches_synchronous(&mut g, doc); } @@ -281,6 +302,175 @@ fn a_redraw_finishing_after_memory_usage_drops_keeps_nothing_over_it() { assert!(g.harness.state().kept.entries.is_empty(), "a composite was kept over the current cap"); } +/// Color Settings › "Blend Text Colors Using Gamma" isn't part of the document, but type layers +/// composite with it: a state drawn before it changed is composited again, not uploaded from +/// blocks drawn with the old gamma. Only the session's setting changes here, not the process-wide +/// gamma compose reads, so tests compositing type layers at the same time aren't affected. +#[test] +fn a_state_drawn_before_the_text_gamma_changed_is_composited_again() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 2600, 1700); + let layer = g.harness.state().session.active().and_then(|st| st.active_layer).expect("layer"); + settle(&mut g); + g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": false})).expect("hide"); + settle(&mut g); + g.harness.state_mut().session.color.settings.blend_text_gamma = 1.0; + g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": true})).expect("show"); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc), "served from blocks of the old gamma: {}", g.harness.state().perf.last_refresh); + settle(&mut g); + assert_eq!(g.harness.state().perf.last_refresh, "background"); + assert_matches_synchronous(&mut g, doc); +} + +/// The text gamma changing while a redraw runs: its blocks were drawn with both, so it isn't kept. +#[test] +fn a_redraw_across_a_text_gamma_change_is_not_kept() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc)); + g.harness.state_mut().session.color.settings.blend_text_gamma = 1.0; + settle(&mut g); + assert!(g.harness.state().kept.entries.is_empty(), "a composite of two gammas was kept"); +} + +/// Changed and changed back while a redraw runs: blocks drawn in between still mix both. +#[test] +fn a_redraw_across_a_text_gamma_change_and_back_is_not_kept() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072); + let gamma = g.harness.state().session.color.settings.blend_text_gamma; + g.harness.step(); + g.harness.state_mut().session.color.settings.blend_text_gamma = 1.0; + g.harness.step(); + g.harness.state_mut().session.color.settings.blend_text_gamma = gamma; + assert!(g.harness.state().redraws.contains_key(&doc), "the redraw finished before the change back"); + settle(&mut g); + assert!(g.harness.state().kept.entries.is_empty(), "a composite of two gammas was kept"); +} + +/// The same while another document is shown: its redraw goes on without being polled, so the +/// change is seen every frame, not only when it shows again. +#[test] +fn a_hidden_documents_redraw_across_a_text_gamma_change_and_back_is_not_kept() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072); + let gamma = g.harness.state().session.color.settings.blend_text_gamma; + g.harness.step(); + g.harness.state_mut().run("file.new", json!({"width": 64, "height": 64})).expect("another document"); + g.harness.step(); + g.harness.state_mut().session.color.settings.blend_text_gamma = 1.0; + g.harness.step(); + g.harness.state_mut().session.color.settings.blend_text_gamma = gamma; + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc), "the hidden document's redraw stopped"); + let i = g.harness.state().session.documents().iter().position(|st| st.doc.id == doc).expect("document"); + g.harness.state_mut().session.set_active(i); + settle(&mut g); + assert!(g.harness.state().kept.entries.iter().all(|k| k.id != doc), "a composite of two gammas was kept"); +} + +/// Memory Usage lowered with no redraw running: the kept composites go at the next frame, not +/// only when the next redraw starts. +#[test] +fn kept_composites_go_when_memory_usage_drops_between_redraws() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + cpu_only_document(g.harness.state_mut(), 2600, 1700); + settle(&mut g); + assert!(!g.harness.state().kept.entries.is_empty(), "the finished redraw is kept"); + g.harness.state_mut().session.edit_prefs(|p| p.performance.memory_usage_mb = 1); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty()); + assert!(g.harness.state().kept.entries.is_empty(), "kept over the current cap"); +} + +/// An edit too small for a background redraw that grows the document past what Memory Usage +/// leaves: the kept composite goes at the next frame. The test holds the kept state itself, so +/// only the cap can drop it, not History letting go of the state. +#[test] +fn kept_composites_go_when_a_small_edit_grows_the_document_past_the_cap() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + cpu_only_document(g.harness.state_mut(), 2600, 1700); + settle(&mut g); + let app = g.harness.state_mut(); + let st = app.session.active().expect("document"); + let held = st.doc.clone(); + let (used, kept) = (st.memory_bytes() as u64, app.kept.bytes()); + assert!(kept > 0, "the finished redraw is kept"); + // Room for the kept composite and 2-3 MB more. + let mb = (used + kept).div_ceil(1 << 20) + 2; + app.session.edit_prefs(|p| p.performance.memory_usage_mb = mb as _); + g.harness.step(); + assert_eq!(g.harness.state().kept.entries.len(), 1, "still within the cap"); + // 2 MP of new pixels (8 MB): under the size that redraws in the background. + for (cmd, p) in + [("layer.new.layer", json!({})), ("select.rect", json!({"x": 0, "y": 0, "width": 2000, "height": 1000})), ("edit.fill", json!({"color": "#cc2244"}))] + { + g.harness.state_mut().run(cmd, p).expect(cmd); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty(), "{cmd} redrew in the background"); + } + assert!(g.harness.state().kept.entries.is_empty(), "kept over the cap after the edit"); + drop(held); +} + +/// The same for an older kept composite of another document: every kept document's edits count, +/// not only the newest one's. +#[test] +fn an_older_kept_composite_goes_when_its_document_grows_past_the_cap() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let a = cpu_only_document(g.harness.state_mut(), 2600, 1700); + settle(&mut g); + let b = cpu_only_document(g.harness.state_mut(), 2600, 1700); + settle(&mut g); + let app = g.harness.state_mut(); + let held: Vec<_> = app.session.documents().iter().map(|st| st.doc.clone()).collect(); + assert_eq!(app.kept.entries.iter().map(|k| k.id).collect::>(), [b, a], "both kept, the second newest"); + let used = app.session.documents().iter().map(|st| st.memory_bytes() as u64).max().unwrap_or(0); + let mb = (used + app.kept.bytes()).div_ceil(1 << 20) + 2; + app.session.edit_prefs(|p| p.performance.memory_usage_mb = mb as _); + let i = app.session.documents().iter().position(|st| st.doc.id == a).expect("document"); + app.session.set_active(i); + g.harness.step(); + assert_eq!(g.harness.state().kept.entries.len(), 2, "still within the cap"); + for (cmd, p) in + [("layer.new.layer", json!({})), ("select.rect", json!({"x": 0, "y": 0, "width": 2000, "height": 1000})), ("edit.fill", json!({"color": "#cc2244"}))] + { + g.harness.state_mut().run(cmd, p).expect(cmd); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty(), "{cmd} redrew in the background"); + } + assert!(g.harness.state().kept.entries.len() < 2, "kept over the cap after the older document grew"); + drop(held); +} + +/// A kept state History no longer holds goes at the next frame: its texels aren't held until +/// some later redraw. +#[test] +fn a_kept_state_history_purged_goes_at_the_next_frame() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + cpu_only_document(g.harness.state_mut(), 2600, 1700); + let layer = g.harness.state().session.active().and_then(|st| st.active_layer).expect("layer"); + settle(&mut g); + // Hidden, the document is drawn on the GPU: the kept state is the one before, in History. + g.harness.state_mut().run("layer.setProps", json!({"layer": layer.0, "visible": false})).expect("hide"); + settle(&mut g); + assert_eq!(g.harness.state().kept.entries.len(), 1, "the state before hiding is kept"); + g.harness.state_mut().run("edit.purge.histories", json!({})).expect("purge"); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty()); + assert!(g.harness.state().kept.entries.is_empty(), "the purged state's composite is still held"); +} + #[test] fn closing_the_document_stops_its_redraw() { let _gpu = photocraft_testkit::gpu_lock(); diff --git a/crates/ui-egui/src/bitmap_dialog.rs b/crates/ui-egui/src/bitmap_dialog.rs index 04ef5b1d79..d7ff658176 100644 --- a/crates/ui-egui/src/bitmap_dialog.rs +++ b/crates/ui-egui/src/bitmap_dialog.rs @@ -70,17 +70,19 @@ fn label(ui: &mut egui::Ui, text: &str) { /// A value stored in per-inch units, edited in per-inch or per-centimeter ones: `field` holds the /// per-inch value, `unit` the view's unit (`per_cm` when its value is per centimeter). -fn per_unit_row(ui: &mut egui::Ui, f: &mut Map, field: &str, range: (f32, f32), default: f32, unit: &str, units: [(&str, &str); 2]) { +// The explicit accessible name accompanies the existing numeric-field parameters. +#[expect(clippy::too_many_arguments)] +fn per_unit_row(ui: &mut egui::Ui, f: &mut Map, field: &str, name: &str, range: (f32, f32), default: f32, unit: &str, units: [(&str, &str); 2]) { let per_inch = num(f, field, default); let mut cur = text(f, unit, units[0].0); let per_cm = cur == units[1].0; let k = if per_cm { CM } else { 1.0 }; let mut shown = per_inch / k; - if crate::widgets::value_field(ui, &mut shown, range.0 / k..=range.1 / k, "", 70.0).changed() { + if crate::widgets::value_field(ui, &mut shown, range.0 / k..=range.1 / k, "", 70.0).accessible_name(name).changed() { f.insert(field.into(), json!(((shown * k).clamp(range.0, range.1) * 1000.0).round() / 1000.0)); } let opts: Vec<(String, &str)> = units.iter().map(|(id, l)| (id.to_string(), *l)).collect(); - if crate::widgets::dropdown(ui, &format!("bitmap-{unit}"), &mut cur, &opts, 150.0) { + if crate::widgets::dropdown(ui, &format!("bitmap-{unit}"), tl!("Units"), &mut cur, &opts, 150.0) { f.insert(unit.into(), json!(cur)); } } @@ -102,7 +104,7 @@ pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = METHODS.iter().map(|(id, l)| (id.to_string(), tl!(l))).collect(); - if crate::widgets::dropdown(ui, "bitmap-method", &mut method, &opts, 190.0) { + if crate::widgets::dropdown(ui, "bitmap-method", tl!("Method"), &mut method, &opts, 190.0) { f.insert("method".into(), json!(method)); } }); @@ -135,12 +137,12 @@ pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = SHAPES.iter().map(|(id, l)| (id.to_string(), tl!(l))).collect(); - if crate::widgets::dropdown(ui, "bitmap-shape", &mut shape, &opts, 150.0) { + if crate::widgets::dropdown(ui, "bitmap-shape", tl!("Shape"), &mut shape, &opts, 150.0) { f.insert("shape".into(), json!(shape)); } }); diff --git a/crates/ui-egui/src/blend_dropdown_wheel_tests.rs b/crates/ui-egui/src/blend_dropdown_wheel_tests.rs index c6b9186f86..f3f69cdd45 100644 --- a/crates/ui-egui/src/blend_dropdown_wheel_tests.rs +++ b/crates/ui-egui/src/blend_dropdown_wheel_tests.rs @@ -48,6 +48,7 @@ fn wheel(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, lines: f32) { h.hover_at(at); h.run_steps(1); h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: egui::TouchPhase::Move, diff --git a/crates/ui-egui/src/blend_keys.rs b/crates/ui-egui/src/blend_keys.rs index d68bc79a11..f954cbd3c4 100644 --- a/crates/ui-egui/src/blend_keys.rs +++ b/crates/ui-egui/src/blend_keys.rs @@ -1,8 +1,10 @@ -//! ⇧+ and ⇧− step through the blend modes, as in Photoshop (#3102): the Brush's or Pencil's mode -//! while one of them is active, the Gradient's with the Gradient tool, and otherwise (Move, the -//! selection tools…) the active layer's. The list wraps at either end. Each press is one call of -//! an engine command (`tools.setBrush`, `layer.setProps`), so it records and undoes like a pick -//! from the dropdown. +//! Blend mode keys, as in Photoshop. ⇧+ and ⇧− step through the modes (#3102), and ⇧⌥ plus a +//! letter picks one (⇧⌥M Multiply, ⇧⌥S Screen…). With a painting tool that has a Mode of its own +//! (`tool_modes`: Brush, Pencil, Paint Bucket, Gradient, the stamps, History Brush, Healing) they +//! set that tool's mode within its menu and never the layer's. With a tool that has no blend mode +//! (Move, the selection tools, the Erasers…) they set every selected layer's mode. +//! The step list wraps at either end. Each press is one call of an engine command +//! (`tools.setBrush`, `layer.setProps` with every target layer), so it is one undo step. use egui::{Event, Key}; use photocraft_color::BlendMode; @@ -13,7 +15,7 @@ use crate::state::Tool; /// The mode `step` places after (or before, when negative) `current` in `modes`, wrapping around. /// A mode that isn't listed steps from the top of the list. -pub fn stepped(modes: &[BlendMode], current: BlendMode, step: i32) -> Option { +pub fn stepped(modes: &[T], current: T, step: i32) -> Option { let n = i32::try_from(modes.len()).ok().filter(|n| *n > 0)?; let at = modes.iter().position(|m| *m == current).and_then(|i| i32::try_from(i).ok()); let next = match at { @@ -24,52 +26,225 @@ pub fn stepped(modes: &[BlendMode], current: BlendMode, step: i32) -> Option Option { + use BlendMode as B; + Some(Pick::Mode(match key { + Key::N => B::Normal, + Key::I => B::Dissolve, + Key::Q => return Some(Pick::Behind), + Key::R => return Some(Pick::Clear), + Key::P => B::PassThrough, + Key::K => B::Darken, + Key::M => B::Multiply, + Key::B => B::ColorBurn, + Key::A => B::LinearBurn, + Key::G => B::Lighten, + Key::S => B::Screen, + Key::D => B::ColorDodge, + Key::W => B::LinearDodge, + Key::O => B::Overlay, + Key::F => B::SoftLight, + Key::H => B::HardLight, + Key::V => B::VividLight, + Key::J => B::LinearLight, + Key::Z => B::PinLight, + Key::L => B::HardMix, + Key::E => B::Difference, + Key::X => B::Exclusion, + Key::U => B::Hue, + Key::T => B::Saturation, + Key::C => B::Color, + Key::Y => B::Luminosity, + _ => return None, + })) +} + +/// One key's request: a step through the list, or a mode picked by letter. +enum Press { + Step(i32), + Pick(Pick), +} + /// Consumes this frame's ⇧+ / ⇧− presses (⇧= on a US layout is `+`; on a German one -/// `layout_keys::apply` has already turned ⇧+ and ⇧- into `+` and `-`) and applies them. +/// `layout_keys::apply` has already turned ⇧+ and ⇧- into `+` and `-`) and ⇧⌥ letters, and +/// applies them. Runs before the tool letters, which would otherwise take ⇧⌥M as M. pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { - let steps: Vec = ctx.input_mut(|i| { + let presses: Vec = ctx.input_mut(|i| { let mut out = Vec::new(); i.events.retain(|e| match e { - Event::Key { key, pressed: true, modifiers, .. } if modifiers.shift && !modifiers.command && !modifiers.ctrl && !modifiers.alt => { - let step = match key { - Key::Plus | Key::Equals => 1, - Key::Minus => -1, - _ => return true, - }; - out.push(step); + Event::Key { key, physical_key, pressed: true, modifiers, .. } if modifiers.shift && !modifiers.command && !modifiers.ctrl => { + if modifiers.alt { + // The layout's letter (⇧⌥Z on a German keyboard is Pin Light), else the + // physical one: on a Mac ⌥ turns the letter into another character (⇧⌥M + // types Â) that may arrive as some other key. + match letter_pick(*key).or_else(|| physical_key.and_then(letter_pick)) { + Some(p) => out.push(Press::Pick(p)), + None => return true, + } + } else { + match key { + Key::Plus | Key::Equals => out.push(Press::Step(1)), + Key::Minus => out.push(Press::Step(-1)), + _ => return true, + } + } false } _ => true, }); out }); - for step in steps { - press(app, step); + for p in presses { + match p { + Press::Step(step) => press(app, step), + Press::Pick(pick) => pick_mode(app, pick), + } } } -/// One ⇧+ (`step` 1) or ⇧− (`step` -1) press. -pub fn press(app: &mut PhotocraftApp, step: i32) { +/// Photoshop's Healing Brush modes, less Replace (not implemented yet). +pub const HEALING_MODES: [BlendMode; 7] = + [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Darken, BlendMode::Lighten, BlendMode::Color, BlendMode::Luminosity]; + +/// The mode a brush-based tool paints with: its brush's, or Normal for a Healing Brush whose brush +/// holds a mode its short menu lacks (the Brush's Overlay remembered from before it had a menu, a +/// preset with tool settings, `tools.setBrush`). The stroke, the dropdown and the keys share it. +pub fn brush_tool_mode(tool: Tool, mode: BlendMode) -> BlendMode { + if tool == Tool::Healing && !HEALING_MODES.contains(&mode) { BlendMode::Normal } else { mode } +} + +/// A painting tool's options-bar Mode menu in order, `None` being Clear (after Dissolve, on the +/// tools Photoshop offers it on). `None` for a tool without a blend mode control. +pub fn tool_modes(tool: Tool) -> Option>> { + let layer = || BlendMode::LAYER_MODES.iter().copied().map(Some).collect::>(); + Some(match tool { + Tool::Brush | Tool::Pencil | Tool::PaintBucket => { + let mut modes = layer(); + let at = modes.iter().position(|m| *m == Some(BlendMode::Dissolve)).map_or(modes.len(), |i| i + 1); + modes.insert(at, None); + modes + } + Tool::Gradient | Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush => layer(), + Tool::Healing => HEALING_MODES.iter().copied().map(Some).collect(), + _ => return None, + }) +} + +/// Where the active tool's blend mode keys go. +enum Target { + /// The tool's own Mode, one of these. + Tool(Vec>), + /// A painting tool with a blend mode in Photoshop but no Mode control here yet: the keys do + /// nothing, and never reach the layer. + Nothing, + Layers, +} + +fn target(tool: Tool) -> Target { + if let Some(modes) = tool_modes(tool) { + return Target::Tool(modes); + } + match tool { + Tool::SpotHealing | Tool::ColorReplacement | Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::ArtHistoryBrush => Target::Nothing, + // Photoshop's ⇧⌥S/M/H pick the Dodge and Burn Range and ⇧⌥D/S the Sponge's mode: never + // the layer's. + Tool::Dodge | Tool::Burn | Tool::Sponge => Target::Nothing, + // No blend mode of their own (the Erasers, the Mixer Brush…): the layer's, as in Photoshop. + _ => Target::Layers, + } +} + +/// The active tool's mode, `None` being Clear. A brush-based tool's lives in its own brush. +pub fn tool_mode(app: &mut PhotocraftApp) -> Option { + let o = &app.ui.tool_options; match app.ui.tool { - Tool::Brush | Tool::Pencil => { - let current = app.session.tools.brush.mode; - if let Some(mode) = stepped(&BlendMode::LAYER_MODES, current, step) - && let Ok(mode) = serde_json::to_value(mode) - { - let _ = app.run("tools.setBrush", json!({ "brush": { "mode": mode } })); - } - } - Tool::Gradient => { - let before = app.ui.tool_options.clone(); - if let Some(mode) = stepped(&BlendMode::LAYER_MODES, before.gradient_blend_mode, step) { - app.ui.tool_options.gradient_blend_mode = mode; - // Live mode: a selected gradient fill layer follows, as with the dropdown. - crate::gradient_ui::options_changed(app, &before); - } - } - // Their blend mode isn't the brush's (the Eraser has none): leave them alone. - t if t.is_brushlike() || t == Tool::MagicEraser || t == Tool::PaintBucket => {} + Tool::Brush if o.brush_clear => None, + Tool::Pencil if o.pencil_clear => None, + Tool::PaintBucket if o.bucket_clear => None, + Tool::PaintBucket => Some(o.bucket_blend_mode), + Tool::Gradient => Some(o.gradient_blend_mode), _ => { + crate::paint_mouse::sync_tool_brush(app); + Some(brush_tool_mode(app.ui.tool, app.session.tools.brush.mode)) + } + } +} + +/// Sets the active tool's mode; `None` is Clear. +fn set_tool_mode(app: &mut PhotocraftApp, mode: Option) { + match app.ui.tool { + Tool::Gradient => { + if let Some(m) = mode { + set_gradient_mode(app, m); + } + return; + } + Tool::PaintBucket => { + app.ui.tool_options.bucket_clear = mode.is_none(); + if let Some(m) = mode { + app.ui.tool_options.bucket_blend_mode = m; + } + return; + } + Tool::Brush => app.ui.tool_options.brush_clear = mode.is_none(), + Tool::Pencil => app.ui.tool_options.pencil_clear = mode.is_none(), + _ => {} + } + crate::paint_mouse::sync_tool_brush(app); + if let Some(m) = mode + && let Ok(m) = serde_json::to_value(m) + { + let _ = app.run("tools.setBrush", json!({ "brush": { "mode": m } })); + } +} + +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. + crate::gradient_ui::options_changed(app, &before); +} + +/// Sets `mode` on every selected layer that can take it, in one undo step: the locked Background +/// has no blend mode, and only groups pass through. +fn set_layers_mode(app: &mut PhotocraftApp, mode: BlendMode) { + let Some(st) = app.session.active() else { return }; + let ids: Vec = st + .selected_layers() + .into_iter() + .filter_map(|id| st.doc.layer(id)) + .filter(|l| !crate::doc_props_ui::is_background(&st.doc, l) && (mode != BlendMode::PassThrough || l.is_group()) && l.blend != mode) + .map(|l| l.id.0) + .collect(); + if !ids.is_empty() { + let _ = app.run("layer.setProps", json!({ "layers": ids, "blend": mode.label() })); + } +} + +/// One ⇧+ (`step` 1) or ⇧− (`step` -1) press. Selected layers all take the mode after the active +/// layer's. +pub fn press(app: &mut PhotocraftApp, step: i32) { + match target(app.ui.tool) { + Target::Tool(modes) => { + let current = tool_mode(app); + if let Some(mode) = stepped(&modes, current, step) { + set_tool_mode(app, mode); + } + } + Target::Nothing => {} + Target::Layers => { let Some(st) = app.session.active() else { return }; let Some(layer) = st.active_layer.and_then(|id| st.doc.layer(id)) else { return }; // The locked Background has no blend mode. @@ -78,13 +253,31 @@ pub fn press(app: &mut PhotocraftApp, step: i32) { } let modes: Vec = std::iter::once(BlendMode::PassThrough).filter(|_| layer.is_group()).chain(BlendMode::LAYER_MODES).collect(); if let Some(mode) = stepped(&modes, layer.blend, step) { - let id = layer.id.0; - let _ = app.run("layer.setProps", json!({ "layer": id, "blend": mode.label() })); + set_layers_mode(app, mode); } } } } +/// One ⇧⌥ letter press. A mode the target's menu doesn't have (Clear or Behind for layers, Pass +/// Through for a painting tool, Overlay for the Healing Brush) does nothing. +pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { + let want = match pick { + Pick::Mode(m) => Some(m), + Pick::Clear => None, + Pick::Behind => return, + }; + match target(app.ui.tool) { + Target::Tool(modes) if modes.contains(&want) => set_tool_mode(app, want), + Target::Layers => { + if let Some(m) = want { + set_layers_mode(app, m); + } + } + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -180,4 +373,343 @@ mod tests { let mut none = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); press(&mut none, 1); } + + /// Feeds one ⇧⌥ press of `key`, reported by the platform as `physical` too. + fn alt_shift(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, physical: Key) { + let m = egui::Modifiers::SHIFT | egui::Modifiers::ALT; + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: m }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, ctx); + ctx.end_pass().textures_delta.clear(); + } + + fn blend_of(app: &PhotocraftApp, i: usize) -> BlendMode { + app.session.active().unwrap().doc.layers[i].blend + } + + /// Photoshop's letters: 26 keys, each a different mode, and the four modes without one. + #[test] + fn every_letter_picks_its_own_mode() { + let keys: Vec = "NIQRPKMBAGSDWOFHVJZLEXUTCY".chars().filter_map(|c| Key::from_name(&c.to_string())).collect(); + assert_eq!(keys.len(), 26); + let picks: Vec = keys.iter().filter_map(|k| letter_pick(*k)).collect(); + assert_eq!(picks.len(), keys.len()); + for (i, p) in picks.iter().enumerate() { + assert!(!picks[..i].contains(p), "{p:?} has two letters"); + } + assert_eq!(letter_pick(Key::M), Some(Pick::Mode(BlendMode::Multiply))); + assert_eq!(letter_pick(Key::W), Some(Pick::Mode(BlendMode::LinearDodge))); + assert_eq!(letter_pick(Key::Y), Some(Pick::Mode(BlendMode::Luminosity))); + for m in [BlendMode::DarkerColor, BlendMode::LighterColor, BlendMode::Subtract, BlendMode::Divide] { + assert!(!picks.contains(&Pick::Mode(m)), "{m:?} has no letter in Photoshop"); + } + for k in [Key::Num1, Key::Space] { + assert_eq!(letter_pick(k), None); + } + } + + /// With the Move tool ⇧⌥M makes the active layer Multiply in one undo step, and doesn't switch + /// to the Marquee tool's M. Clear and Behind are painting modes: a layer ignores them. + #[test] + fn alt_shift_letter_sets_the_layer_mode_and_keeps_the_tool() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Move); + let undo = a.session.active().unwrap().history.past_len(); + alt_shift(&mut a, &ctx, Key::M, Key::M); + assert_eq!(layer_blend(&a), BlendMode::Multiply); + assert_eq!(a.ui.tool, Tool::Move, "not the Marquee tool"); + assert_eq!(a.session.active().unwrap().history.past_len(), undo + 1); + alt_shift(&mut a, &ctx, Key::R, Key::R); + alt_shift(&mut a, &ctx, Key::Q, Key::Q); + alt_shift(&mut a, &ctx, Key::P, Key::P); + assert_eq!(layer_blend(&a), BlendMode::Multiply, "no Clear, Behind or Pass Through on a layer"); + assert_eq!(a.ui.tool, Tool::Move); + // ⇧⌥ letters that are tool keys pick modes too. + alt_shift(&mut a, &ctx, Key::S, Key::S); + assert_eq!(layer_blend(&a), BlendMode::Screen); + assert_eq!(a.ui.tool, Tool::Move); + } + + /// A Mac's ⌥ turns the letter into another character; egui then reports the physical key. + /// A German layout's Z (physical Y) is still Pin Light: the typed letter wins. + #[test] + fn the_typed_letter_wins_and_the_physical_key_is_the_fallback() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Move); + alt_shift(&mut a, &ctx, Key::Backtick, Key::O); + assert_eq!(layer_blend(&a), BlendMode::Overlay); + alt_shift(&mut a, &ctx, Key::Z, Key::Y); + assert_eq!(layer_blend(&a), BlendMode::PinLight); + } + + /// Every selected layer takes the mode, all in one undo step; the Background keeps Normal. + /// ⇧+ steps them all to the mode after the active layer's. + #[test] + fn mode_keys_set_every_selected_layer_in_one_undo_step() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Move); + let ids: Vec = a.session.active().unwrap().doc.layers.iter().map(|l| l.id.0).collect(); + a.run("layer.new.layer", json!({})).unwrap(); + for id in &ids { + a.run("layer.select", json!({"layer": id, "mode": "add"})).unwrap(); + } + assert_eq!(a.session.active().unwrap().selected_layers().len(), 3); + let undo = a.session.active().unwrap().history.past_len(); + alt_shift(&mut a, &ctx, Key::S, Key::S); + assert_eq!([blend_of(&a, 0), blend_of(&a, 1), blend_of(&a, 2)], [BlendMode::Normal, BlendMode::Screen, BlendMode::Screen]); + assert_eq!(a.session.active().unwrap().history.past_len(), undo + 1, "one undo step for both layers"); + key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT); + assert_eq!([blend_of(&a, 1), blend_of(&a, 2)], [BlendMode::ColorDodge, BlendMode::ColorDodge]); + assert_eq!(a.session.active().unwrap().history.past_len(), undo + 2); + a.session.undo(); + assert_eq!([blend_of(&a, 1), blend_of(&a, 2)], [BlendMode::Screen, BlendMode::Screen]); + } + + /// ⇧⌥P is Pass Through, for groups only. + #[test] + fn pass_through_is_for_groups() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Move); + a.run("layer.new.group", json!({})).unwrap(); + alt_shift(&mut a, &ctx, Key::N, Key::N); + assert_eq!(layer_blend(&a), BlendMode::Normal); + alt_shift(&mut a, &ctx, Key::P, Key::P); + assert_eq!(layer_blend(&a), BlendMode::PassThrough); + } + + /// With the Brush the letters set the brush's mode, Clear included, and never the layer's; + /// ⇧+ from Dissolve reaches Clear, as in the dropdown. The Pencil keeps its own Clear. + #[test] + fn with_a_painting_tool_the_letters_set_only_its_mode() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Brush); + alt_shift(&mut a, &ctx, Key::M, Key::M); + assert_eq!(a.session.tools.brush.mode, BlendMode::Multiply); + assert_eq!(layer_blend(&a), BlendMode::Normal, "the layer is untouched"); + assert_eq!(a.ui.tool, Tool::Brush); + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert!(a.ui.tool_options.brush_clear); + alt_shift(&mut a, &ctx, Key::P, Key::P); + assert!(a.ui.tool_options.brush_clear, "no Pass Through for a brush"); + alt_shift(&mut a, &ctx, Key::I, Key::I); + assert!(!a.ui.tool_options.brush_clear); + assert_eq!(a.session.tools.brush.mode, BlendMode::Dissolve); + key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT); + assert!(a.ui.tool_options.brush_clear, "Clear follows Dissolve"); + key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT); + assert!(!a.ui.tool_options.brush_clear); + assert_eq!(a.session.tools.brush.mode, BlendMode::Darken); + assert!(!a.ui.tool_options.pencil_clear); + a.ui.tool = Tool::Pencil; + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert!(a.ui.tool_options.pencil_clear); + assert_eq!(layer_blend(&a), BlendMode::Normal); + } + + /// The Gradient takes the letters; Smudge, which has a blend mode in Photoshop but no Mode + /// control here yet, ignores them rather than changing the layer. + #[test] + fn gradient_takes_the_letters_and_smudge_ignores_them() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Gradient); + alt_shift(&mut a, &ctx, Key::O, Key::O); + assert_eq!(a.ui.tool_options.gradient_blend_mode, BlendMode::Overlay); + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert_eq!(a.ui.tool_options.gradient_blend_mode, BlendMode::Overlay, "no Clear for the Gradient"); + a.ui.tool = Tool::Smudge; + alt_shift(&mut a, &ctx, Key::M, Key::M); + assert_eq!(layer_blend(&a), BlendMode::Normal); + assert_eq!(a.ui.tool, Tool::Smudge); + let mut none = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + pick_mode(&mut none, Pick::Mode(BlendMode::Multiply)); + } + + /// With a Free Transform box open, ⇧⌥ letters and the number keys still go where the tool + /// sends them: the Brush's mode and opacity with the Brush, the layer's with the Move tool. + /// They never pick a tool, so the box stays open, and Enter keeps both the transform and the + /// layer's new mode. Puppet Warp keeps them to itself: its preview is a snapshot taken when it + /// started, so a change made meanwhile wouldn't show, and Esc wouldn't take it back. + #[test] + fn keys_follow_the_tool_while_a_transform_is_open_and_a_warp_keeps_them() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Brush); + a.run("select.rect", json!({"x": 4, "y": 4, "width": 12, "height": 12})).unwrap(); + a.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + a.run("select.deselect", json!({})).unwrap(); + a.sync_views(); + crate::transform_tool::begin(&mut a, &ctx).unwrap(); + crate::transform_tool::nudge(&mut a, 3.0, 0.0); + let shift_alt = egui::Modifiers::SHIFT | egui::Modifiers::ALT; + let opacity = |a: &PhotocraftApp| { + let st = a.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().opacity + }; + // The Brush: its own mode and opacity. + key(&mut a, &ctx, Key::M, shift_alt); + assert_eq!(a.session.tools.brush.mode, BlendMode::Multiply); + key(&mut a, &ctx, Key::Num5, egui::Modifiers::NONE); + assert!((a.session.tools.brush.opacity - 0.5).abs() < 1e-6); + assert_eq!((layer_blend(&a), opacity(&a)), (BlendMode::Normal, 1.0), "the layer is left alone"); + assert!(a.ui.transform.is_some(), "the box stays open"); + assert_eq!(a.ui.tool, Tool::Brush, "no tool letter"); + // The Move tool: the layer's. + a.ui.tool = Tool::Move; + a.opacity_keys = None; + key(&mut a, &ctx, Key::E, shift_alt); + key(&mut a, &ctx, Key::Num7, egui::Modifiers::NONE); + assert_eq!(layer_blend(&a), BlendMode::Difference); + assert!((opacity(&a) - 0.7).abs() < 1e-6, "{}", opacity(&a)); + assert!(a.ui.transform.is_some()); + key(&mut a, &ctx, Key::Enter, egui::Modifiers::NONE); + assert!(a.ui.transform.is_none()); + let st = a.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert_eq!(l.blend, BlendMode::Difference, "the commit keeps it"); + assert_eq!(l.surface().unwrap().content_bounds().x0, 7, "and the move"); + let l_opacity = l.opacity; + // Puppet Warp: nothing reaches the layer, the Brush or the History. + crate::distort_ui::menu(&mut a, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + assert!(a.distort.puppet.is_some()); + let undo = a.session.active().unwrap().history.past_len(); + a.opacity_keys = None; + key(&mut a, &ctx, Key::S, shift_alt); + key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT); + key(&mut a, &ctx, Key::Num3, egui::Modifiers::NONE); + a.ui.tool = Tool::Brush; + key(&mut a, &ctx, Key::H, shift_alt); + key(&mut a, &ctx, Key::Num9, egui::Modifiers::NONE); + assert_eq!((layer_blend(&a), opacity(&a)), (BlendMode::Difference, l_opacity), "the layer under the warp is left alone"); + assert_eq!(a.session.tools.brush.mode, BlendMode::Multiply); + assert!((a.session.tools.brush.opacity - 0.5).abs() < 1e-6); + assert_eq!(a.session.active().unwrap().history.past_len(), undo, "no History step behind the warp"); + assert!(a.distort.puppet.is_some(), "the warp stays open"); + // Perspective Warp too. + key(&mut a, &ctx, Key::Escape, egui::Modifiers::NONE); + assert!(a.distort.puppet.is_none()); + a.ui.tool = Tool::Move; + crate::distort_ui::menu(&mut a, &ctx, "edit.perspectiveWarp", &json!({})).unwrap().unwrap(); + assert!(a.distort.perspective.is_some()); + key(&mut a, &ctx, Key::S, shift_alt); + key(&mut a, &ctx, Key::Num3, egui::Modifiers::NONE); + assert_eq!((layer_blend(&a), opacity(&a)), (BlendMode::Difference, l_opacity)); + assert_eq!(a.session.active().unwrap().history.past_len(), undo); + assert!(a.distort.perspective.is_some()); + } + + /// The stamps and the History Brush keep their mode in their own brush: the keys set it there, + /// never the layer's, and another tool's brush keeps its own. + #[test] + fn clone_stamp_pattern_stamp_and_history_brush_take_the_keys_into_their_own_brush() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Brush); + alt_shift(&mut a, &ctx, Key::M, Key::M); + for (tool, key, mode) in + [(Tool::CloneStamp, Key::S, BlendMode::Screen), (Tool::PatternStamp, Key::O, BlendMode::Overlay), (Tool::HistoryBrush, Key::K, BlendMode::Darken)] + { + a.ui.tool = tool; + assert_eq!(tool_mode(&mut a), Some(BlendMode::Normal), "{tool:?} starts in Normal"); + alt_shift(&mut a, &ctx, key, key); + assert_eq!(tool_mode(&mut a), Some(mode), "{tool:?}"); + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert_eq!(tool_mode(&mut a), Some(mode), "{tool:?} has no Clear"); + key_press(&mut a, &ctx, 1); + assert_ne!(tool_mode(&mut a), Some(mode), "{tool:?} steps"); + assert_eq!(layer_blend(&a), BlendMode::Normal, "{tool:?} leaves the layer alone"); + } + a.ui.tool = Tool::Brush; + assert_eq!(tool_mode(&mut a), Some(BlendMode::Multiply), "the Brush keeps its own mode"); + } + + fn key_press(a: &mut PhotocraftApp, ctx: &egui::Context, step: i32) { + key(a, ctx, if step > 0 { Key::Plus } else { Key::Minus }, egui::Modifiers::SHIFT); + } + + /// The Healing Brush's menu is short: letters outside it do nothing, and ⇧+ steps within it. + #[test] + fn healing_brush_takes_only_its_own_modes() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Healing); + alt_shift(&mut a, &ctx, Key::O, Key::O); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Normal), "Overlay isn't a Healing mode"); + alt_shift(&mut a, &ctx, Key::Y, Key::Y); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Luminosity)); + key_press(&mut a, &ctx, 1); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Normal), "wraps within the Healing list"); + key_press(&mut a, &ctx, 1); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Multiply)); + assert_eq!(layer_blend(&a), BlendMode::Normal); + } + + /// A Healing Brush whose brush holds Overlay (outside its menu) counts as Normal for the keys, + /// as for its stroke: ⇧+ steps on to Multiply. + #[test] + fn healing_brush_with_a_mode_outside_its_menu_steps_from_normal() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Healing); + crate::paint_mouse::sync_tool_brush(&mut a); + a.session.tools.brush.mode = BlendMode::Overlay; + assert_eq!(tool_mode(&mut a), Some(BlendMode::Normal)); + key_press(&mut a, &ctx, 1); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Multiply)); + } + + /// The Paint Bucket has its own Mode, Clear included. + #[test] + fn paint_bucket_takes_the_keys_with_clear() { + let ctx = egui::Context::default(); + let mut a = app(Tool::PaintBucket); + alt_shift(&mut a, &ctx, Key::M, Key::M); + assert_eq!(a.ui.tool_options.bucket_blend_mode, BlendMode::Multiply); + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert!(a.ui.tool_options.bucket_clear); + key_press(&mut a, &ctx, 1); + assert!(!a.ui.tool_options.bucket_clear); + assert_eq!(a.ui.tool_options.bucket_blend_mode, BlendMode::Darken, "Clear is followed by Darken"); + assert_eq!(layer_blend(&a), BlendMode::Normal); + } + + /// Tools without a blend mode of their own (the Erasers, the Mixer Brush) pass the keys to the + /// selected layers, as Photoshop does. + #[test] + fn tools_without_a_blend_mode_set_the_layer() { + let ctx = egui::Context::default(); + for (tool, key, mode) in + [(Tool::Eraser, Key::M, BlendMode::Multiply), (Tool::MagicEraser, Key::S, BlendMode::Screen), (Tool::MixerBrush, Key::E, BlendMode::Difference)] + { + let mut a = app(tool); + alt_shift(&mut a, &ctx, key, key); + assert_eq!(layer_blend(&a), mode, "{tool:?}"); + assert_eq!(a.ui.tool, tool); + } + } + + /// Dodge, Burn and Sponge use ⇧⌥S/M/H and ⇧⌥D/S for their own Range and mode in Photoshop, so + /// neither the letters nor ⇧+ change the layer's blend mode. + #[test] + fn dodge_burn_and_sponge_leave_the_layer_alone() { + let ctx = egui::Context::default(); + for tool in [Tool::Dodge, Tool::Burn, Tool::Sponge] { + let mut a = app(tool); + for k in [Key::S, Key::M, Key::H, Key::D] { + alt_shift(&mut a, &ctx, k, k); + } + key_press(&mut a, &ctx, 1); + assert_eq!(layer_blend(&a), BlendMode::Normal, "{tool:?}"); + assert_eq!(a.ui.tool, tool); + } + } + + #[test] + fn art_history_brush_leaves_the_layer_alone() { + // Photoshop's Art History Brush has a Mode of its own; ours has no Mode control yet. + let ctx = egui::Context::default(); + let mut a = app(Tool::ArtHistoryBrush); + alt_shift(&mut a, &ctx, Key::M, Key::M); + key_press(&mut a, &ctx, 1); + assert_eq!(layer_blend(&a), BlendMode::Normal); + assert_eq!(a.ui.tool, Tool::ArtHistoryBrush); + } } diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index 06e7a208e1..37617ea6cf 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/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/brand.rs b/crates/ui-egui/src/brand.rs index a601e95c3e..01cf827d35 100644 --- a/crates/ui-egui/src/brand.rs +++ b/crates/ui-egui/src/brand.rs @@ -22,7 +22,7 @@ fn decode() -> egui::ColorImage { /// The icon texture, uploaded on first use and kept in the context. fn texture(ctx: &Context) -> TextureHandle { - let id = Id::new("photocraft-brand-mark"); + let id = Id::unique("photocraft-brand-mark"); if let Some(tex) = ctx.data(|d| d.get_temp::(id)) { return tex; } @@ -34,7 +34,7 @@ fn texture(ctx: &Context) -> TextureHandle { /// Where the mark was painted this frame (tests and screenshots). fn rect_id() -> Id { - Id::new("photocraft-brand-mark-rect") + Id::unique("photocraft-brand-mark-rect") } /// Paint the mark into `r` (square). diff --git a/crates/ui-egui/src/browse_field_tests.rs b/crates/ui-egui/src/browse_field_tests.rs new file mode 100644 index 0000000000..09debf418b --- /dev/null +++ b/crates/ui-egui/src/browse_field_tests.rs @@ -0,0 +1,83 @@ +//! #7942 / #7531: path fields of command dialogs get a Browse… button (Artboards to Files' +//! folder, Install Plug-in…'s .wasm file) that fills the field from a file dialog. +use crate::PhotocraftApp; +use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::{Value, json}; + +fn harness(answers: Vec>) -> (Harness<'static, PhotocraftApp>, std::rc::Rc>>) { + let (show, asked) = crate::file_dialog::fake(answers); + let 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(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }) + }); + (h, asked) +} + +fn field(h: &Harness<'_, PhotocraftApp>, key: &str) -> Option { + h.state().ui.dialogs.last().and_then(|d| d.fields.get(key).cloned()) +} + +#[test] +fn artboards_to_files_folder_is_chosen_with_browse() { + let (mut h, asked) = harness(vec![Some(FileDialogAnswer::FolderTo("/tmp/exports".into()))]); + h.state_mut().run("file.new", json!({"width": 64, "height": 64})).unwrap(); + h.state_mut().sync_views(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.export.artboardsToFiles", json!({})).unwrap(); + h.run_steps(4); + assert!(field(&h, "dir").is_some(), "the dialog has a Dir field"); + 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/exports"))); + assert!(field(&h, crate::dialogs::BROWSE_FIELD).is_none(), "the request is consumed"); +} + +#[test] +fn cancelling_browse_keeps_the_typed_folder() { + let (mut h, _) = harness(vec![None]); + h.state_mut().run("file.new", json!({"width": 64, "height": 64})).unwrap(); + h.state_mut().sync_views(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.export.layersToFiles", json!({})).unwrap(); + h.run_steps(4); + let before = field(&h, "dir"); + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert_eq!(field(&h, "dir"), before); +} + +#[test] +fn install_plugin_path_is_chosen_with_browse() { + let (mut h, asked) = harness(vec![Some(FileDialogAnswer::Paths(vec!["/tmp/blur.wasm".into()]))]); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "plugin.install", json!({})).unwrap(); + h.run_steps(4); + h.get_by_label("Browse…").click(); + h.run_steps(4); + match asked.borrow().as_slice() { + [FileDialogRequest::Open { multiple: false, extensions: Some(e), .. }] => assert_eq!(e, &["wasm".to_string()]), + other => panic!("unexpected requests {other:?}"), + } + assert_eq!(field(&h, "path"), Some(json!("/tmp/blur.wasm"))); +} + +/// #9357: Image Processor's source and destination folders are chosen with Browse…, as +/// Photoshop's Select Folder… buttons choose them, instead of being typed. +#[test] +fn image_processor_folders_are_chosen_with_browse() { + let (mut h, asked) = harness(vec![Some(FileDialogAnswer::FolderTo("/tmp/shoot".into())), Some(FileDialogAnswer::FolderTo("/tmp/web".into()))]); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.scripts.imageProcessor", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(h.query_all_by_label("Browse…").count(), 2, "one Browse… for the source and one for the destination"); + h.query_all_by_label("Browse…").next().unwrap().click(); + h.run_steps(4); + assert_eq!(field(&h, "input"), Some(json!("/tmp/shoot"))); + h.query_all_by_label("Browse…").nth(1).unwrap().click(); + h.run_steps(4); + assert_eq!(field(&h, "output"), Some(json!("/tmp/web"))); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Folder { .. }, FileDialogRequest::Folder { .. }])); +} diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index 49c444c154..6bc71a78f7 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -50,6 +50,47 @@ pub(crate) fn tip_axes(xf: &crate::canvas::ViewXform, size: f32) -> [Vec2; 2] { [xf.map_vec(Vec2::new(r, 0.0)), xf.map_vec(Vec2::new(0.0, r))] } +/// Apply pen orientation after the cached tip shape, in document space before the view. +/// Keeping this outside `rings` avoids retracing a sampled tip on every tablet sample. +pub(crate) fn pen_axes(xf: &crate::canvas::ViewXform, brush: &BrushSettings, pen: Option) -> [Vec2; 2] { + use photocraft_paint::brush::Control; + use photocraft_paint::dynamics::{StepInput, apply_pose, control_angle}; + let axes = tip_axes(xf, brush.size); + if !brush.shape_dynamics.enabled { + return axes; + } + let Some(pen) = pen else { return axes }; + let pen = pen.sanitized(); + let point = apply_pose( + brush, + photocraft_paint::StrokePoint { pressure: pen.pressure, tilt_x: pen.tilt_x, tilt_y: pen.tilt_y, rotation: pen.rotation, ..Default::default() }, + ); + let sd = &brush.shape_dynamics; + let step = StepInput { point, direction: 0.0, initial_direction: 0.0, step: 0 }; + let mut angle = match sd.angle.control { + Control::PenTilt | Control::Rotation => control_angle(sd.angle.control, sd.angle.fade_steps, &step), + _ => 0.0, + }; + if sd.brush_projection { + angle += point.rotation; + } + let (sn, cs) = angle.to_radians().sin_cos(); + let project = |v: Vec2| { + if !sd.brush_projection { + return v; + } + let tilt = point.tilt_x.hypot(point.tilt_y).clamp(0.0, 90.0); + if tilt == 0.0 { + return v; + } + let direction = Vec2::new(point.tilt_x, point.tilt_y).normalized(); + v + direction * v.dot(direction) * (tilt.to_radians().cos().max(0.05) - 1.0) + }; + let x = project(Vec2::new(cs, -sn)); + let y = project(Vec2::new(sn, cs)); + [axes[0] * x.x + axes[1] * x.y, axes[0] * y.x + axes[1] * y.y] +} + /// Rings in radius units (document orientation) on screen, through [`tip_axes`]. fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec> { rings.iter().map(|r| r.iter().map(|p| axes[0] * p[0] + axes[1] * p[1]).collect()).collect() @@ -59,7 +100,12 @@ fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec> { /// for the plain circle; `axes` comes from [`tip_axes`], for a traced outline. `normal` is /// Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, axes: [Vec2; 2], normal: bool, centre: bool) -> CursorIcon { - if let Some(rings) = rings(painter.ctx(), brush, !normal) { + let anisotropic = (axes[0].length_sq() - axes[1].length_sq()).abs() > 0.001 || axes[0].dot(axes[1]).abs() > 0.001; + let outline = rings(painter.ctx(), brush, !normal).or_else(|| { + // A round tip becomes an ellipse under projection (or a non-square pixel aspect). + anisotropic.then(|| photocraft_paint::outline::tip_outline(brush, !normal).map(Arc::new)).flatten() + }); + if let Some(rings) = outline { let pts = on_screen(&rings, axes); let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length())); if extent < MIN_RADIUS { @@ -77,7 +123,7 @@ pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> O if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 { return None; } - let id = egui::Id::new("photocraft-tip-outline"); + let id = egui::Id::unique("photocraft-tip-outline"); if let Some(c) = ctx.data(|d| d.get_temp::(id)) && c.key.matches(brush, full) { @@ -139,6 +185,76 @@ mod tests { (a - b).length() < 1e-4 } + /// Component timing only: outline transformation and OS bitmap generation, not presentation + /// or tablet latency. Run explicitly in release mode on the same runner for both cases. + #[test] + #[ignore] + fn pen_cursor_cost_probe() { + let mut brush = BrushSettings { size: 60.0, roundness: 0.25, hardness: 1.0, ..Default::default() }; + brush.shape_dynamics.enabled = true; + brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation; + let xf = view(0.0, false, 1.0); + for dynamic in [false, true] { + let ctx = egui::Context::default(); + for sample in 0..5 { + let start = std::time::Instant::now(); + let _ = ctx.run_ui(Default::default(), |ui| { + for i in 0..360 { + let axes = if dynamic { + pen_axes(&xf, &brush, Some(crate::stylus::PenSample { rotation: i as f32, ..Default::default() })) + } else { + tip_axes(&xf, brush.size) + }; + std::hint::black_box(cursor(ui.painter(), pos2(100.0, 100.0), &brush, 60.0, axes, false, false)); + } + }); + println!("PEN_CURSOR_COST dynamic={dynamic} sample={sample} us_per_update={:.3}", start.elapsed().as_secs_f64() * 1e6 / 360.0); + } + } + } + + #[test] + fn pen_orientation_preserves_mouse_and_disabled_dynamics() { + let mut brush = BrushSettings::default(); + brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation; + let pen = crate::stylus::PenSample { rotation: 90.0, ..Default::default() }; + let xf = view(30.0, true, 2.0); + assert_eq!(pen_axes(&xf, &brush, Some(pen)), tip_axes(&xf, brush.size)); + brush.shape_dynamics.enabled = true; + assert_eq!(pen_axes(&xf, &brush, None), tip_axes(&xf, brush.size)); + } + + #[test] + fn pen_pose_rotation_precedes_the_view_and_reuses_the_tip_cache() { + let mut brush = BrushSettings { size: 20.0, roundness: 0.25, ..Default::default() }; + brush.shape_dynamics.enabled = true; + brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation; + brush.pose.enabled = true; + brush.pose.override_rotation = true; + brush.pose.rotation = 90.0; + let ctx = egui::Context::default(); + let cached = rings(&ctx, &brush, true).unwrap(); + for (rotation, flip, aspect) in [(0.0, false, 1.0), (30.0, true, 2.0)] { + let xf = view(rotation, flip, aspect); + let axes = pen_axes(&xf, &brush, Some(crate::stylus::PenSample::default())); + assert!(close(axes[0], xf.map_vec(Vec2::new(0.0, -10.0)))); + assert!(close(axes[1], xf.map_vec(Vec2::new(10.0, 0.0)))); + assert!(Arc::ptr_eq(&cached, &rings(&ctx, &brush, true).unwrap())); + } + } + + #[test] + fn brush_projection_shortens_the_tilt_axis() { + let mut brush = BrushSettings { size: 20.0, ..Default::default() }; + brush.shape_dynamics.enabled = true; + brush.shape_dynamics.brush_projection = true; + let xf = view(0.0, false, 1.0); + let axes = pen_axes(&xf, &brush, Some(crate::stylus::PenSample { tilt_x: 60.0, ..Default::default() })); + assert!(close(axes[0], Vec2::new(10.0, 0.0))); + assert!(close(axes[1], Vec2::new(0.0, 20.0))); + assert_eq!(pen_axes(&xf, &brush, Some(crate::stylus::PenSample::default())), tip_axes(&xf, brush.size)); + } + /// The axes are the screen offsets of the document points one radius right of and below the /// pointer, whatever the view does. #[test] diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 332c80c4b7..4b46204266 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -98,8 +98,9 @@ pub struct BrushesPanelState { pub show_stroke: bool, /// The Brush Preset picker's cards: show the tip thumbnail. pub show_tip: bool, - /// The Brush Preset picker's cards: their size scale, from the picker's footer slider - /// (`crate::brush_picker::body`). 1 is the standard size; the cards' width, height and + /// The Brush Preset picker's cards and the Brushes tab's grid cells and list rows: their size + /// scale, from the picker's footer slider (`crate::brush_picker::body`) or the tab's preview-size + /// slider. 1 is the standard size; the cards' width, height and /// insides multiply by it (`crate::brushes_tab`) — the height down to a 0.50 floor, the /// width all the way — the text holds its 1.0 size, and at or below the compact threshold /// the tips drop the size number under them and take their cell whole. @@ -191,7 +192,7 @@ fn gesture(ctx: &egui::Context) -> u64 { let frame = ctx.cumulative_pass_nr(); let pressed = ctx.input(|i| i.pointer.any_pressed()); ctx.data_mut(|d| { - let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)); + let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::unique(GESTURE)); if pressed && g.1 != frame { g.0 += 1; g.1 = frame; @@ -206,12 +207,12 @@ fn gesture(ctx: &egui::Context) -> u64 { pub fn commit_gesture(app: &mut PhotocraftApp, ctx: &egui::Context, before: &BrushSettings, after: &BrushSettings) { let g = gesture(ctx); if send(app, before, after, Some(format!("brush-ui:{g}"))) && !ctx.input(|i| i.pointer.any_down()) { - ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)).0 += 1); + ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::unique(GESTURE)).0 += 1); } } /// Group label for presets saved without a group. -pub const UNGROUPED: &str = "My Brushes"; +pub const UNGROUPED: &str = photocraft_engine::brush_key_cmds::UNGROUPED; /// Preset indices in panel order: groups in order of first appearance. pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec)> { @@ -273,9 +274,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting // Named for accessibility and for tests and agents that look sections up by name. if toggle_only { let checked = section_flag(b, i).is_some_and(|flag| *flag); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, tl!(*name))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, checked, tl!(*name))); } else { - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, tl!(*name))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, tl!(*name))); } if sel { ui.painter().rect_filled(r, t.radius_sm, t.accent_soft); @@ -288,9 +289,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting // A checkbox-only row is one accessible checkbox across its whole width. A // controls section keeps its separate enable box and selectable label. let box_resp = (!toggle_only).then(|| { - let resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("brush-sec-box", i)), Sense::click()); resp.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *flag, crate::i18n::fmt(tl!("Enable {name}"), &[("name", tl!(*name))])) + egui::WidgetInfo::selected(egui::Role::CheckBox, true, *flag, crate::i18n::fmt(tl!("Enable {name}"), &[("name", tl!(*name))])) }); resp }); @@ -322,10 +323,14 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting let mut lock_clicked = false; if let Some(lock) = section_lock(b, i) { let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0)); - let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click()); + let lresp = ui.interact(lr, ui.scope_id().with(("brush-sec-lock", i)), Sense::click()); + lresp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), *lock, format!("{}: {}", tl!("Lock"), tl!(*name)))); // Like Photoshop: lock icons stay visible on every lockable section row. let tint = if *lock { t.text } else { t.text_faint }; icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); + if lresp.has_focus() { + ui.painter().rect_stroke(lr, t.radius_sm, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } let tip = if *lock { tl!("Unlock: picking a preset replaces these settings") } else { tl!("Lock: keep these settings when picking another preset") }; if lresp.on_hover_text(tip).clicked() { @@ -368,9 +373,11 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The picked preset is recorded by name (`brush.presets.setCurrent`, identity, so // look-alike duplicates stay distinct), since the engine can't infer it from the // brush afterwards. - ui.add_enabled_ui(on, |ui| section_body(ui, &mut b, section, &app.session.tools.presets, app.session.tools.current_preset.as_deref())) - .inner - .inspect(|name| run_or_status(app, "brush.presets.setCurrent", json!({ "name": name }))); + ui.add_enabled_ui(on, |ui| { + section_body(ui, &mut b, section, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), app.ui.tool) + }) + .inner + .inspect(|name| run_or_status(app, "brush.presets.setCurrent", json!({ "name": name }))); }); }); }); @@ -395,7 +402,7 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let current = app.session.tools.current_preset.clone().filter(|n| app.session.tools.presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))); let update = ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "check", 24.0, false, tl!("Update the current brush with these settings"))).inner; - update.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, current.is_some(), "Update the current brush with these settings")); + update.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, current.is_some(), "Update the current brush with these settings")); if update.clicked() { run_or_status(app, "brush.presets.update", json!({ "brush": serde_json::to_value(&b).unwrap_or(Value::Null) })); } @@ -504,7 +511,7 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { let canvas = app.last_canvas_rect; let mut open = true; egui::Window::new(tl!("Brush Settings")) - .id(egui::Id::new("brush-settings")) + .id(egui::Id::unique("brush-settings")) .title_bar(false) .resizable(false) .frame(frame) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index f38a1b9f0c..e0e15cb4f5 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1,5 +1,6 @@ use egui::vec2; use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; use photocraft_engine::paint::{Control, GrayTile, MaskMode, Pattern, PatternStyle, TipShape}; use super::*; @@ -12,6 +13,14 @@ fn last_journal(app: &PhotocraftApp) -> Option<(String, Value)> { app.session.journal.last().cloned() } +/// The visible options caption, not the numeric field it names. +fn caption_rect(h: &Harness<'_, PhotocraftApp>, text: &str) -> egui::Rect { + h.query_all(egui_kittest::kittest::by().label(text).include_labels()) + .find(|n| n.accesskit_node().role() == egui::accesskit::Role::Label) + .unwrap_or_else(|| panic!("the `{text}` caption")) + .rect() +} + #[test] fn sections_with_boxes_have_flags() { let mut b = BrushSettings::default(); @@ -112,7 +121,8 @@ fn edit_section(i: usize, b: &mut BrushSettings) { p.enabled = true; p.tilt_x = 45.0; p.tilt_y = -27.0; - p.override_tilt = true; + p.override_tilt_x = true; + p.override_tilt_y = true; p.rotation = 120.0; p.override_rotation = true; p.pressure = 0.4; @@ -283,6 +293,122 @@ fn section_locks_are_always_visible_and_toggle_on_click() { assert_eq!(h.state().ui.brush_section, 0); } +#[test] +fn section_lock_buttons_report_their_section_and_state_in_every_theme() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + + for theme in crate::theme::ThemeKind::ALL { + let mut h = harness(0, 3); + PhotocraftApp::setup_context(&h.ctx, theme); + let mut brush = h.state().session.tools.brush.clone(); + for i in 1..SECTIONS.len() { + *section_lock(&mut brush, i).unwrap() = i % 2 == 0; + } + h.state_mut().session.tools.brush = brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + h.run_steps(3); + for (i, &(name, _)) in SECTIONS.iter().enumerate().skip(1) { + let label = format!("Lock: {name}"); + let node = h.get_by_role_and_label(Role::Button, &label); + assert!(!node.accesskit_node().is_disabled(), "locks remain available for disabled sections"); + assert_eq!(h.query_all_by_label(&label).count(), 1, "{theme:?}: {name} has one distinct control"); + assert_eq!(node.accesskit_node().toggled(), Some(if i % 2 == 0 { Toggled::True } else { Toggled::False }), "{theme:?}: {name}"); + let row = h.get_by_label(name).rect(); + assert!(row.contains_rect(node.rect()), "the named control is the lock's existing hit target"); + } + assert_eq!(h.state().session.tools.brush, brush, "describing a lock must not edit it"); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().ui.brush_section, 3); + } +} + +#[test] +fn section_lock_buttons_accept_keyboard_without_selecting_or_enabling_sections() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + + for (i, &(name, _)) in SECTIONS.iter().enumerate().skip(1) { + let mut h = harness(0, 0); + let before = h.state().session.tools.brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + let label = format!("Lock: {name}"); + let mut replay = photocraft_engine::Session::new(); + replay.tools.brush = before.clone(); + h.get_by_role_and_label(Role::Button, &label).focus(); + h.run_steps(2); + for locked in [true, false] { + h.key_press(egui::Key::Space); + h.run_steps(3); + let mut expected = before.clone(); + *section_lock(&mut expected, i).unwrap() = locked; + assert_eq!(h.state().session.tools.brush, expected, "{name}: only this section's lock changes"); + assert_eq!(h.state().ui.brush_section, 0); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.get_by_role_and_label(Role::Button, &label).accesskit_node().toggled(), Some(if locked { Toggled::True } else { Toggled::False })); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"].as_object().unwrap().len(), 1); + assert_eq!(params["brush"]["locks"].as_object().unwrap().len(), 1); + assert_eq!(params["brush"]["locks"].as_object().unwrap().values().next(), Some(&json!(locked))); + replay.execute(&id, params).unwrap(); + assert_eq!(replay.tools.brush, expected, "command replay preserves the lock-only edit"); + } + assert_eq!(h.state().session.journal.len(), journal + 2, "one command per activation"); + assert_eq!(replay.tools.brush, before); + } +} + +#[test] +fn section_lock_buttons_show_focus_and_track_keyboard_navigation() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + fn has_ring(shape: &egui::Shape, target: egui::Rect, color: egui::Color32) -> bool { + match shape { + egui::Shape::Rect(r) => r.rect == target && r.stroke == Stroke::new(1.0, color) && r.stroke_kind == egui::StrokeKind::Inside, + egui::Shape::Vec(v) => v.iter().any(|s| has_ring(s, target, color)), + _ => false, + } + } + for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() { + let mut h = harness(0, 0); + PhotocraftApp::setup_context(&h.ctx, theme); + h.state_mut().session.tools.brush.locks.scattering = index % 2 == 0; + h.state_mut().session.tools.brush.locks.texture = index % 2 != 0; + h.run_steps(2); + let before = h.state().session.tools.brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + let first = h.get_by_role_and_label(Role::Button, "Lock: Scattering").rect(); + let second = h.get_by_role_and_label(Role::Button, "Lock: Texture").rect(); + let accent = Tokens::get(&h.ctx).accent; + let ring = |h: &Harness<'_, PhotocraftApp>, rect| h.output().shapes.iter().any(|s| s.clip_rect.contains_rect(rect) && has_ring(&s.shape, rect, accent)); + assert!(!ring(&h, first) && !ring(&h, second), "{theme:?}: unfocused locks have no focus ring"); + h.get_by_role_and_label(Role::Button, "Lock: Scattering").focus(); + h.run_steps(2); + assert!(h.get_by_role_and_label(Role::Button, "Lock: Scattering").is_focused()); + assert!(ring(&h, first) && !ring(&h, second), "{theme:?}: the focused lock needs a visible indicator"); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert!(!h.get_by_role_and_label(Role::Button, "Lock: Scattering").is_focused()); + assert!(!ring(&h, first), "Tab removes the old lock's indicator"); + h.get_by_role_and_label(Role::Button, "Lock: Texture").focus(); + h.run_steps(2); + assert!(!ring(&h, first) && ring(&h, second), "the ring follows focus"); + h.get_by_label("Brush Tip Shape").focus(); + h.run_steps(2); + assert!(!ring(&h, first) && !ring(&h, second)); + assert_eq!(h.state().session.tools.brush, before, "focus must not edit settings"); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().ui.brush_section, 0); + } +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); @@ -303,11 +429,10 @@ fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftA #[test] fn clone_stamp_opacity_and_flow_are_journaled_per_gesture() { - use egui_kittest::kittest::Queryable; let mut h = options_bar_harness(crate::state::Tool::CloneStamp); for (label, field) in [("Opacity", "opacity"), ("Flow", "flow")] { let n = h.state().session.journal.len(); - let rect = h.get_by_label(label).rect(); + let rect = caption_rect(&h, label); let start = egui::pos2(rect.right() + 40.0, rect.center().y); h.hover_at(start); h.run(); @@ -347,7 +472,7 @@ fn options_bar_edits_are_one_set_brush_per_gesture() { h.run_steps(3); assert_eq!(h.state().session.journal.len(), n, "showing the bar issues nothing"); // Drag the Opacity field left: many frames, one journal entry. - let label = h.get_by_label("Opacity").rect(); + let label = caption_rect(&h, "Opacity"); let start = egui::pos2(label.right() + 40.0, label.center().y); h.hover_at(start); h.run(); @@ -620,6 +745,66 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn transfer_mixer_edits_use_the_brush_command_and_survive_real_tool_swaps() { + use crate::state::Tool; + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut h = harness(0, 6); + let a = h.state_mut(); + a.ui.tool = Tool::Brush; + a.session.tools.brush.transfer.enabled = true; + a.session.tools.brush.transfer.wetness = + paint::Dynamic { jitter: 0.41, minimum: 0.43, control: Control::PenPressure, fade_steps: 339, ..Default::default() }; + a.session.tools.brush.transfer.mix = paint::Dynamic { jitter: 0.63, minimum: 0.67, control: Control::PenPressure, fade_steps: 449, ..Default::default() }; + crate::paint_mouse::sync_tool_brush(a); + a.ui.tool = Tool::MixerBrush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + h.hover_at(caption_rect(&h, "Opacity Jitter").center()); + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -250.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(20); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert!((h.state().session.tools.brush.transfer.wetness.jitter - 0.42).abs() < 1e-6); + assert_eq!(h.state().session.journal.len(), journal + 1); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"], json!({"transfer": {"wetness": {"jitter": 0.42_f32}}})); + let mixer = h.state().session.tools.brush.clone(); + + let a = h.state_mut(); + a.ui.tool = Tool::Brush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + let brush = h.state().session.tools.brush.clone(); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, brush); + assert_eq!(h.state().session.journal.len(), journal + 1, "inactive edits and tool swaps add no brush command"); + let a = h.state_mut(); + a.ui.tool = Tool::MixerBrush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, mixer, "the Mixer Brush keeps its own edited settings"); + assert!( + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .any(|node| node.accesskit_node().numeric_value() == Some(42.0) && !node.accesskit_node().is_disabled()) + ); +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { @@ -633,6 +818,55 @@ fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { brush.pointer(path).and_then(Value::as_bool).expect("the checkbox has a boolean brush setting") } +#[test] +fn pose_axes_window_routes_each_checkbox_through_a_minimal_command() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut h = harness(0, 7); + h.state_mut() + .session + .execute( + "tools.setBrush", + json!({"pose": { + "enabled": true, "tiltX": 45, "tiltY": -18, "overrideTilt": true, + "pressure": 0.71, "rotation": 67 + }}), + ) + .unwrap(); + h.run_steps(3); + let before = h.state().session.tools.brush.clone(); + let n = h.state().session.journal.len(); + h.get_by_role_and_label(Role::CheckBox, "Override Tilt X").click(); + h.run_steps(3); + assert_eq!(h.get_by_label("Override Tilt X").accesskit_node().toggled(), Some(Toggled::False)); + assert_eq!(h.get_by_label("Override Tilt Y").accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(h.state().session.journal.len(), n + 1); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"], json!({"pose": {"overrideTiltX": false}})); + let mut expected = serde_json::to_value(before).unwrap(); + expected["pose"]["overrideTiltX"] = json!(false); + expected["pose"]["overrideTilt"] = json!(false); + assert_eq!(serde_json::to_value(&h.state().session.tools.brush).unwrap(), expected); + h.get_by_role_and_label(Role::CheckBox, "Override Tilt Y").click(); + h.run_steps(3); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"pose": {"overrideTiltY": false}})); + let before = h.state().session.tools.brush.clone(); + h.state_mut().session.execute("tools.setBrush", json!({"pose": {"enabled": false}})).unwrap(); + h.run_steps(3); + let n = h.state().session.journal.len(); + for label in ["Override Tilt X", "Override Tilt Y"] { + let node = h.get_by_role_and_label(Role::CheckBox, label); + assert!(node.accesskit_node().is_disabled()); + node.click(); + h.run_steps(2); + } + let mut expected = before; + expected.pose.enabled = false; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(h.state().session.journal.len(), n, "a disabled pose records no checkbox update"); +} + #[test] fn hue_jitter_amount_edits_keep_percentage_units_and_command_routing() { use egui::accesskit::Role; @@ -778,12 +1012,11 @@ fn checkbox_only_rows_are_accessible_checkboxes_and_accept_space() { #[test] fn editing_the_options_bar_amount_reenables_smoothing() { - use egui_kittest::kittest::Queryable; let mut h = options_bar_harness(crate::state::Tool::Brush); h.state_mut().run("tools.setBrush", json!({"brush": {"smoothing": {"enabled": false, "amount": 0.4}}})).unwrap(); h.run_steps(3); let n = h.state().session.journal.len(); - let label = h.get_by_label("Smoothing").rect(); + let label = caption_rect(&h, "Smoothing"); let at = egui::pos2(label.right() + 25.0, label.center().y); h.hover_at(at); h.run_steps(2); @@ -1153,7 +1386,6 @@ fn brush_sections_paint_simplified_chinese_labels() { for (name, _) in SECTIONS { let translated = crate::i18n::tr(zh, name); assert!(painted.iter().any(|s| s == translated), "{name} must paint as {translated}"); - assert!(!painted.iter().any(|s| s == name), "{name} must not paint in English"); } } diff --git a/crates/ui-egui/src/brush_picker.rs b/crates/ui-egui/src/brush_picker.rs index 9bef83a647..e417dc5a77 100644 --- a/crates/ui-egui/src/brush_picker.rs +++ b/crates/ui-egui/src/brush_picker.rs @@ -76,7 +76,7 @@ impl Pick { } pub(crate) fn picker_id() -> egui::Id { - egui::Id::new("canvas-brush-picker") + egui::Id::unique("canvas-brush-picker") } /// All opening paths protect the same frame, including the control channel and options bar. @@ -88,7 +88,7 @@ pub(crate) fn open(ui: &mut crate::state::UiState, ctx: &egui::Context, at: [f32 /// The options-bar brush chip that shows and hides the picker. A press on it isn't an outside /// press that closes the picker: the chip's click toggles it. pub fn chip_id() -> egui::Id { - egui::Id::new("brush-preset-chip") + egui::Id::unique("brush-preset-chip") } /// Is screen point `p` on the options-bar brush chip? @@ -197,7 +197,7 @@ pub fn settings_toggle(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// An icon button's accessible name (icon buttons have only a tooltip). pub(crate) fn named(resp: egui::Response, label: &str) -> egui::Response { - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(label))); resp } @@ -234,7 +234,7 @@ pub fn body( ui.label(RichText::new(tl!("Size")).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut size = b.size; - if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).changed() { + if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() { b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE); } }); @@ -260,7 +260,7 @@ pub fn body( ui.horizontal(|ui| { icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint); ui.add_space(20.0); - widgets::text_field(ui, ui.id().with("brush-picker-search"), &mut st.filter, tl!("Search Brushes"), (size.x - 118.0).max(60.0), false); + widgets::text_field(ui, ui.scope_id().with("brush-picker-search"), &mut st.filter, tl!("Search Brushes"), (size.x - 118.0).max(60.0), false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { gear_menu(ui, current, presets, st, &mut picks); let new_tip = "Create new brush preset from the current settings"; @@ -330,7 +330,7 @@ pub fn clamp_size(size: [f32; 2], screen: egui::Vec2) -> egui::Vec2 { pub fn resize_grip(ui: &mut egui::Ui, frame: egui::Rect, content: egui::Vec2) -> Option { let t = Tokens::get(ui.ctx()); let grip = egui::Rect::from_min_size(frame.right_bottom() - vec2(16.0, 16.0), vec2(16.0, 16.0)); - let resp = ui.interact(grip, ui.id().with("brush-picker-resize"), egui::Sense::drag()); + let resp = ui.interact(grip, ui.scope_id().with("brush-picker-resize"), egui::Sense::drag()); dismiss_popup(&resp); let corner = grip.right_bottom() - vec2(3.0, 3.0); for i in 0..3 { diff --git a/crates/ui-egui/src/brush_preset_dialog.rs b/crates/ui-egui/src/brush_preset_dialog.rs index 129b9adf28..46bf07522e 100644 --- a/crates/ui-egui/src/brush_preset_dialog.rs +++ b/crates/ui-egui/src/brush_preset_dialog.rs @@ -96,8 +96,8 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, ui.horizontal(|ui| { ui.label(tl!("Name:")); let mut text = f.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - let id = ui.id().with("new-brush-preset-name"); - let r = widgets::text_field(ui, id, &mut text, "", 220.0, false); + let id = ui.scope_id().with("new-brush-preset-name"); + let r = widgets::text_field(ui, id, &mut text, "", 220.0, false).accessible_name(tl!("Name:")); // Photoshop opens on the name, all of it selected, so typing replaces it. let once = id.with("focused"); if !ui.ctx().data(|d| d.get_temp::(once).unwrap_or(false)) { diff --git a/crates/ui-egui/src/brush_preset_dialog_tests.rs b/crates/ui-egui/src/brush_preset_dialog_tests.rs index 3e13ebc381..c8bc461cd7 100644 --- a/crates/ui-egui/src/brush_preset_dialog_tests.rs +++ b/crates/ui-egui/src/brush_preset_dialog_tests.rs @@ -65,7 +65,8 @@ fn every_new_preset_button_opens_the_dialog_and_cancel_saves_nothing() { h.run_steps(3); cancel(&mut h); h.state_mut().ui.brush_tab = 1; - h.run_steps(3); + // The window grows by the preview-size slider row over several frames; wait for it to settle. + h.run_steps(8); h.get_by_label("Create new brush from the current settings").click(); h.run_steps(3); cancel(&mut h); diff --git a/crates/ui-egui/src/brush_preview.rs b/crates/ui-egui/src/brush_preview.rs index ec6b36c07b..f77975d45c 100644 --- a/crates/ui-egui/src/brush_preview.rs +++ b/crates/ui-egui/src/brush_preview.rs @@ -220,7 +220,7 @@ fn upload(ctx: &egui::Context, name: &str, w: u32, h: u32, rgba: &[u8]) -> egui: } fn cache_id() -> egui::Id { - egui::Id::new("brush-preview-cache") + egui::Id::unique("brush-preview-cache") } /// Run `f` on the context's preview cache. The cache is taken out of egui's memory while `f` runs, diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index 7d41aaa1ba..5ff7beb9c9 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -308,7 +308,7 @@ mod tests { assert!(h.state().brush_resize.is_some()); assert!(h.state().session.tools.brush.size > size0, "dragging right grew the brush"); assert!(h.state().session.tools.brush.hardness > 0.2, "dragging down hardened it"); - assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("brush-resize-readout"))).is_some(), "the readout shows mid-drag"); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::unique("brush-resize-readout"))).is_some(), "the readout shows mid-drag"); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the pointer hides"); press(&mut h, c + vec2(60.0, 48.0), false); h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); @@ -370,7 +370,7 @@ mod tests { assert!(r.secondary); assert!(h.state().session.tools.brush.size > size0, "right grows the brush"); assert!(h.state().session.tools.brush.hardness > 0.2, "down hardens it"); - assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("brush-resize-readout"))).is_some(), "the readout shows"); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::unique("brush-resize-readout"))).is_some(), "the readout shows"); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None); button(&mut h, end, PointerButton::Secondary, false, Modifiers::ALT); h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); @@ -384,6 +384,27 @@ mod tests { } } + /// Alt-right dragging changes tip size without loading paint or touching document history. + #[test] + fn mixer_alt_right_drag_resizes_without_sampling_or_painting() { + let mut a = app(Tool::MixerBrush); + let before = a.session.tools.mixer.clone(); + a.sync_views(); + let mut h = canvas(a); + let (undo0, size0) = (history(h.state()), h.state().session.tools.brush.size); + let end = alt_right_drag(&mut h, vec2(10.0, 8.0)); + assert!(h.state().brush_resize.is_some_and(|resize| resize.secondary)); + assert!(h.state().session.tools.brush.size > size0); + button(&mut h, end, PointerButton::Secondary, false, Modifiers::ALT); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let a = h.state(); + assert!(a.brush_resize.is_none() && !a.brush_resize_armed); + assert_eq!(a.session.tools.mixer, before); + assert_eq!(history(a), undo0); + assert!(!a.session.journal.iter().any(|(id, _)| id == "brush.mixer.sample" || id == "paint.mixerBrush")); + } + #[test] fn plain_right_click_still_opens_the_picker() { let mut a = app(Tool::Brush); diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index 9e66f9b0a4..d75a4daad1 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -8,6 +8,7 @@ use photocraft_engine::BrushSettings; use photocraft_engine::paint::{self, BrushPreset, Control, Dynamic, MAX_BRUSH_SIZE, MaskMode, Pattern, PatternStyle, TipShape}; use crate::brush_preview; +use crate::state::Tool; use crate::theme::{self, Tokens}; use crate::widgets; @@ -75,7 +76,7 @@ pub(crate) fn takes_curve(control: Control, angle: bool) -> bool { /// egui memory key of the open/closed state of a dynamic's curve editor. pub(crate) fn curve_open_id(id: &str) -> egui::Id { - egui::Id::new(("brush-curve-open", id)) + egui::Id::unique(("brush-curve-open", id)) } /// `Control: [Off ▾] [25] [~]`: the controller pop-up, Fade's step count and the button that @@ -87,10 +88,10 @@ fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) { ui.horizontal(|ui| { dim(ui, tl!("Control:")); let n = if angle || matches!(d.control, Control::InitialDirection | Control::Direction) { 8 } else { 6 }; - widgets::dropdown(ui, id, &mut d.control, &CONTROLS[..n], 130.0); + widgets::dropdown(ui, id, tl!("Control:"), &mut d.control, &CONTROLS[..n], 130.0); if d.control == Control::Fade { let mut steps = d.fade_steps as f32; - if widgets::value_field(ui, &mut steps, 1.0..=9999.0, "", 56.0).on_hover_text(tl!("Fade steps")).changed() { + if widgets::value_field(ui, &mut steps, 1.0..=9999.0, "", 56.0).accessible_name(tl!("Fade steps")).on_hover_text(tl!("Fade steps")).changed() { d.fade_steps = steps.round().clamp(1.0, 9999.0) as u32; } } @@ -100,7 +101,7 @@ fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) { }); if open && takes_curve(d.control, angle) { let mut points = d.curve.points(); - if crate::pressure_curve_ui::edit_points(ui, egui::Id::new(("brush-curve-rect", id)), &mut points) { + if crate::pressure_curve_ui::edit_points(ui, egui::Id::unique(("brush-curve-rect", id)), &mut points) { d.curve = paint::ResponseCurve::new(&points); } } @@ -131,7 +132,7 @@ fn size_row(ui: &mut egui::Ui, label: &str, size: &mut f32, max: f32, restore: O ui.label(RichText::new(tl!(label)).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut s = *size; - if widgets::value_field(ui, &mut s, 1.0..=max, "px", 74.0).changed() { + if widgets::value_field(ui, &mut s, 1.0..=max, "px", 74.0).accessible_name(tl!(label)).changed() { *size = s.round().clamp(1.0, max); } if let Some(orig) = restore @@ -331,7 +332,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], ui.horizontal(|ui| { ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); let mut a = b.angle; - if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).changed() { + if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() { b.angle = a.round().clamp(-180.0, 180.0); } }); @@ -339,7 +340,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], ui.horizontal(|ui| { ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); let mut r = (b.roundness * 100.0).round(); - if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).changed() { + if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).accessible_name(tl!("Roundness:")).changed() { b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); } }); @@ -453,7 +454,7 @@ fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) { if style.is_none() { opts.push((None, tl!("Custom"))); } - if widgets::dropdown(ui, "brush-pattern", &mut style, &opts, 110.0) + if widgets::dropdown(ui, "brush-pattern", tl!("Pattern"), &mut style, &opts, 110.0) && let Some(s) = style { let size = match &tx.pattern { @@ -487,7 +488,7 @@ fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) { ui.add_space(4.0); ui.horizontal(|ui| { dim(ui, tl!("Mode:")); - widgets::dropdown(ui, "brush-texture-mode", &mut tx.mode, &MASK_MODES, 130.0); + widgets::dropdown(ui, "brush-texture-mode", tl!("Mode"), &mut tx.mode, &MASK_MODES, 130.0); }); ui.add_space(4.0); pct(ui, tl!("Depth"), &mut tx.depth, 1.0); @@ -503,7 +504,7 @@ fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) let d = &mut b.dual_brush; ui.horizontal(|ui| { dim(ui, tl!("Mode:")); - widgets::dropdown(ui, "brush-dual-mode", &mut d.mode, &MASK_MODES, 120.0); + widgets::dropdown(ui, "brush-dual-mode", tl!("Mode"), &mut d.mode, &MASK_MODES, 120.0); ui.add_space(8.0); widgets::checkbox(ui, &mut d.flip, tl!("Flip")); }); @@ -542,12 +543,16 @@ fn color_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { signed_pct(ui, tl!("Purity"), &mut c.purity, -1.0, 1.0); } -fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) { +fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings, tool: Tool) { let tr = &mut b.transfer; dynamic(ui, "brush-opacity-ctl", tl!("Opacity Jitter"), &mut tr.opacity, 1.0, Some(tl!("Minimum")), false); dynamic(ui, "brush-flow-ctl", tl!("Flow Jitter"), &mut tr.flow, 1.0, Some(tl!("Minimum")), false); - dynamic(ui, "brush-wet-ctl", tl!("Wetness Jitter"), &mut tr.wetness, 1.0, Some(tl!("Minimum")), false); - dynamic(ui, "brush-mix-ctl", tl!("Mix Jitter"), &mut tr.mix, 1.0, Some(tl!("Minimum")), false); + // Wetness and mix only affect the Mixer Brush. Keep their settings visible and saved + // for other tools, but disable the whole blocks, including controllers and minimums. + ui.add_enabled_ui(tool == Tool::MixerBrush, |ui| { + dynamic(ui, "brush-wet-ctl", tl!("Wetness Jitter"), &mut tr.wetness, 1.0, Some(tl!("Minimum")), false); + dynamic(ui, "brush-mix-ctl", tl!("Mix Jitter"), &mut tr.mix, 1.0, Some(tl!("Minimum")), false); + }); } /// The Mixer Brush options the brush carries (Photoshop shows them in the Mixer Brush's options @@ -580,8 +585,10 @@ fn tilt(ui: &mut egui::Ui, label: &str, deg: &mut f32) { fn pose(ui: &mut egui::Ui, b: &mut BrushSettings) { let p = &mut b.pose; tilt(ui, tl!("Tilt X"), &mut p.tilt_x); + widgets::checkbox(ui, &mut p.override_tilt_x, tl!("Override Tilt X")); + ui.add_space(8.0); tilt(ui, tl!("Tilt Y"), &mut p.tilt_y); - widgets::checkbox(ui, &mut p.override_tilt, tl!("Override Tilt")); + widgets::checkbox(ui, &mut p.override_tilt_y, tl!("Override Tilt Y")); ui.add_space(8.0); num(ui, tl!("Rotation"), &mut p.rotation, 0.0..=360.0, "°"); widgets::checkbox(ui, &mut p.override_rotation, tl!("Override Rotation")); @@ -602,7 +609,8 @@ fn smoothing(ui: &mut egui::Ui, b: &mut BrushSettings) { /// Controls for section `i` (see [`crate::brush_panel::SECTIONS`]). `presets` feed the tip grids. /// Returns the name of a preset whose tip was picked in the Brush Tip Shape grid, for /// `brush.presets.setCurrent`. -pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset], current: Option<&str>) -> Option { +/// The active `tool` determines which Transfer controls apply. +pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset], current: Option<&str>, tool: Tool) -> Option { let t = Tokens::get(ui.ctx()); let note = |ui: &mut egui::Ui, s: &str| { ui.label(RichText::new(s).color(t.text_faint).font(theme::medium(11.5))); @@ -617,7 +625,7 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: 4 => dual_brush(ui, b, presets), 5 => color_dynamics(ui, b), 6 => { - transfer(ui, b); + transfer(ui, b, tool); mixer_options(ui, b); } 7 => pose(ui, b), @@ -643,6 +651,14 @@ mod tests { kittest::{NodeT, Queryable}, }; + /// The slider row's visible caption, not the field or slider it names. + fn caption_rect(h: &Harness<'_, T>, text: &str) -> egui::Rect { + h.query_all(egui_kittest::kittest::by().label(text).include_labels()) + .find(|n| n.accesskit_node().role() == Role::Label) + .unwrap_or_else(|| panic!("the `{text}` caption")) + .rect() + } + fn minimum_brush() -> BrushSettings { let mut b = BrushSettings::default(); b.shape_dynamics.size.minimum = 0.2; @@ -663,7 +679,7 @@ mod tests { ready = true; return; } - section_body(ui, b, section, &[], None); + section_body(ui, b, section, &[], None, Tool::MixerBrush); }, b, ); @@ -671,6 +687,40 @@ mod tests { h } + #[test] + fn pose_axes_controls_are_independent_and_below_each_tilt_in_all_themes() { + use egui::accesskit::Toggled; + for kind in theme::ThemeKind::ALL { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": true, "tiltX": 45.0, "tiltY": -18.0, + "overrideTiltX": true, "overrideTiltY": false, + "pressure": 0.71, "rotation": 67.0 + }})) + .unwrap(); + let mut h = section_harness(7, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let x = h.get_by_role_and_label(Role::CheckBox, "Override Tilt X"); + let y = h.get_by_role_and_label(Role::CheckBox, "Override Tilt Y"); + assert_eq!(x.accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(y.accesskit_node().toggled(), Some(Toggled::False)); + assert!(caption_rect(&h, "Tilt X").bottom() < x.rect().top() && x.rect().bottom() < caption_rect(&h, "Tilt Y").top()); + assert!(caption_rect(&h, "Tilt Y").bottom() < y.rect().top() && y.rect().bottom() < caption_rect(&h, "Rotation").top()); + assert_eq!(h.state(), &b, "drawing preserves pose degrees and flags"); + y.focus(); + h.run_steps(1); + h.key_press(egui::Key::Space); + h.run_steps(2); + let wire = serde_json::to_value(h.state()).unwrap(); + assert_eq!(wire["pose"]["overrideTiltX"], true); + assert_eq!(wire["pose"]["overrideTiltY"], true); + let mut expected = serde_json::to_value(b).unwrap(); + expected["pose"]["overrideTiltY"] = serde_json::json!(true); + expected["pose"]["overrideTilt"] = serde_json::json!(true); + assert_eq!(wire, expected, "Space changes only Y, plus the derived legacy readback"); + } + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { @@ -680,7 +730,7 @@ mod tests { let mut h = section_harness(5, b.clone()); crate::PhotocraftApp::setup_context(&h.ctx, kind); h.run_steps(3); - let label = h.get_by_label("Hue Jitter").rect(); + let label = caption_rect(&h, "Hue Jitter"); let t = Tokens::get(&h.ctx); assert!( h.output().shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) @@ -694,6 +744,134 @@ mod tests { } } + struct TransferState { + brush: BrushSettings, + tool: Tool, + } + + fn transfer_mixer_brush() -> BrushSettings { + let mut b = BrushSettings::default(); + b.transfer.enabled = true; + for (dynamic, jitter, minimum, fade_steps) in [ + (&mut b.transfer.opacity, 0.13, 0.17, 119), + (&mut b.transfer.flow, 0.27, 0.29, 229), + (&mut b.transfer.wetness, 0.41, 0.43, 339), + (&mut b.transfer.mix, 0.63, 0.67, 449), + ] { + *dynamic = Dynamic { control: Control::Fade, jitter, minimum, fade_steps, ..Default::default() }; + } + b + } + + fn transfer_mixer_harness(kind: theme::ThemeKind, tool: Tool) -> Harness<'static, TransferState> { + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(340.0, 1400.0)).build_ui_state( + move |ui, state: &mut TransferState| { + if !ready { + crate::PhotocraftApp::setup_context(ui.ctx(), kind); + ready = true; + return; + } + ui.add_enabled_ui(state.brush.transfer.enabled, |ui| section_body(ui, &mut state.brush, 6, &[], None, state.tool)); + }, + TransferState { brush: transfer_mixer_brush(), tool }, + ); + h.run_steps(3); + h + } + + /// #3825: Mixer-only blocks follow the tool, including sliders, minimums and Fade steps. + #[test] + fn transfer_mixer_controls_follow_the_tool_and_section_in_every_theme() { + for kind in theme::ThemeKind::ALL { + let mut h = transfer_mixer_harness(kind, Tool::Brush); + let before = h.state().brush.clone(); + for tool in Tool::ALL { + h.state_mut().tool = tool; + h.run_steps(2); + for (mixer_only, values) in [(false, [13.0, 17.0, 119.0, 27.0, 29.0, 229.0]), (true, [41.0, 43.0, 339.0, 63.0, 67.0, 449.0])] { + for value in values { + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + assert_eq!(field.accesskit_node().is_disabled(), mixer_only && tool != Tool::MixerBrush, "{kind:?}, {tool:?}, {value}"); + } + } + for (index, node) in h.query_all_by_role(Role::ComboBox).enumerate() { + assert_eq!(node.accesskit_node().is_disabled(), index >= 2 && tool != Tool::MixerBrush, "{kind:?}, {tool:?}: controller {index}"); + } + assert_eq!(h.state().brush, before, "changing tool context only changes availability"); + } + h.state_mut().tool = Tool::MixerBrush; + h.state_mut().brush.transfer.enabled = false; + h.run_steps(2); + assert!(h.query_all_by_role(Role::SpinButton).all(|node| node.accesskit_node().is_disabled()), "section Off still disables every field"); + assert!(h.query_all_by_role(Role::ComboBox).all(|node| node.accesskit_node().is_disabled())); + h.state_mut().brush.transfer.enabled = true; + h.state_mut().brush.transfer.wetness.control = Control::Off; + h.run_steps(2); + let minimum = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + assert!(minimum.accesskit_node().is_disabled(), "Control Off still disables the applicable minimum"); + } + } + + #[test] + fn transfer_mixer_controls_ignore_disabled_edits_and_keep_values_on_reactivation() { + let mut h = transfer_mixer_harness(theme::ThemeKind::ProMedium, Tool::Brush); + let before = h.state().brush.clone(); + for value in [41.0, 43.0, 339.0, 63.0, 67.0, 449.0] { + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + let at = field.rect().center(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.run_steps(2); + h.hover_at(at); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at + vec2(60.0, 0.0)); + h.run_steps(2); + assert_eq!(h.state().brush, before, "inactive field {value} ignores keyboard and pointer input"); + } + for label in ["Wetness Jitter", "Mix Jitter"] { + let row = caption_rect(&h, label); + let from = pos2(row.left() + 120.0, row.bottom() + 16.0); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + vec2(150.0, 0.0)); + h.run_steps(1); + h.drop_at(from + vec2(150.0, 0.0)); + h.run_steps(2); + assert_eq!(h.state().brush, before, "the inactive {label} track ignores a drag"); + } + h.state_mut().tool = Tool::MixerBrush; + h.run_steps(2); + let row = caption_rect(&h, "Wetness Jitter"); + let from = pos2(row.left() + 120.0, row.bottom() + 16.0); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + vec2(150.0, 0.0)); + h.run_steps(1); + h.drop_at(from + vec2(150.0, 0.0)); + h.run_steps(2); + assert!(h.state().brush.transfer.wetness.jitter > 0.6, "the same track accepts a drag when applicable"); + h.state_mut().brush.transfer.wetness.jitter = 0.41; + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert!((h.state().brush.transfer.wetness.jitter - 0.42).abs() < 1e-6); + let after = h.state().brush.clone(); + h.state_mut().tool = Tool::Brush; + h.run_steps(2); + h.state_mut().tool = Tool::MixerBrush; + h.run_steps(2); + assert_eq!(h.state().brush, after); + assert!(h.query_all_by_role(Role::SpinButton).any(|node| node.accesskit_node().numeric_value() == Some(42.0) && !node.accesskit_node().is_disabled())); + } + fn set_controls(b: &mut BrushSettings, control: Control) { for d in [ &mut b.shape_dynamics.size, @@ -762,7 +940,7 @@ mod tests { // The slider below the number and its scrubby label are disabled too. let track = pos2(300.0, center.y + 24.0); drag(&mut h, track, track); - let label = h.get_by_label("Minimum Diameter").rect().center(); + let label = caption_rect(&h, "Minimum Diameter").center(); drag(&mut h, label, label + vec2(60.0, 0.0)); assert_eq!(h.state(), &before, "disabled interactions never rewrite brush settings"); diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index aacb09867e..050d810603 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -333,12 +333,27 @@ fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: & /// Width a list row keeps for the gaps around its stroke preview and the preset name. const STROKE_NAME_ROOM: f32 = 140.0; -fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], indent: f32, acts: &mut Vec) { +/// The Brushes tab's preview size slider range: what the grid cells and list rows scale between +/// (1 = the standard size). Not the picker's cards' scale ([`crate::brush_picker::SCALE_RANGE`]). +pub(crate) const PREVIEW_SCALE_RANGE: std::ops::RangeInclusive = 0.5..=2.0; + +/// A list row's height at preview `scale` (1 = the standard 44 points). +pub(crate) fn list_row_height(scale: f32) -> f32 { + 44.0 * if scale.is_finite() { scale.clamp(*PREVIEW_SCALE_RANGE.start(), *PREVIEW_SCALE_RANGE.end()) } else { 1.0 } +} + +/// A grid cell's width at preview `scale` (1 = the layout's own cell). +pub(crate) fn grid_cell_width(cell: f32, scale: f32) -> f32 { + cell * if scale.is_finite() { scale.clamp(*PREVIEW_SCALE_RANGE.start(), *PREVIEW_SCALE_RANGE.end()) } else { 1.0 } +} + +fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], indent: f32, scale: f32, acts: &mut Vec) { let t = Tokens::get(ui.ctx()); - let (full, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click_and_drag()); + let k = list_row_height(scale) / 44.0; + let (full, resp) = ui.allocate_exact_size(vec2(ui.available_width(), list_row_height(scale)), Sense::click_and_drag()); // Rows inside nested folders are indented; the whole width stays the drop target. let r = egui::Rect::from_min_max(full.min + vec2(indent.min(full.width() / 2.0), 0.0), full.max); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, &p.name)); if !ui.is_rect_visible(r) { return; } @@ -349,8 +364,8 @@ fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushP } let pb = &p.brush; let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text); - let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0)); - let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size)); + let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0) * k); + let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * k * crate::brush_sections::thumb_scale(pb.size)); ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE); ui.painter().text( pos2(cell.center().x, r.bottom() - 1.0), @@ -363,7 +378,7 @@ fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushP // texture slot carries the width, so lists of different widths don't re-render each other's. let w = (r.width() - (cell.right() - r.left()) - STROKE_NAME_ROOM).clamp(60.0, 170.0).round(); let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke-{w}:{}", p.name), pb, w as u32, 36, t.text); - let sr = egui::Rect::from_min_size(pos2(cell.right() + 8.0, r.top() + 4.0), vec2(w, 36.0)); + let sr = egui::Rect::from_min_size(pos2(cell.right() + 8.0, r.top() + 4.0 * k), vec2(w, 36.0 * k)); ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE); crate::layer_row_ui::label(ui.painter(), sr.right() + 12.0, r.center().y, r.right() - 4.0, &p.name, egui::FontId::proportional(12.0), t.text_dim); preset_interactions(ui, &resp, r, p, presets, false, acts); @@ -392,7 +407,7 @@ pub(crate) fn grid_columns(width: f32, cell: f32, indent: f32, gap: f32) -> (f32 fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], cell: f32, acts: &mut Vec) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(cell, cell + 6.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, &p.name)); if !ui.is_rect_visible(r) { return; } @@ -404,7 +419,7 @@ fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[Brush } let pb = &p.brush; let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text); - let side = cell - 14.0; + let side = (cell - 14.0).max(8.0); let ir = egui::Rect::from_center_size(pos2(r.center().x, r.top() + side / 2.0 + 4.0), vec2(side, side) * crate::brush_sections::thumb_scale(pb.size)); ui.painter().image(tip.id(), ir, full_uv(), Color32::WHITE); ui.painter().text( @@ -540,7 +555,7 @@ fn preset_card( 0.0 }; let (full, resp) = ui.allocate_exact_size(vec2(width, pad_y * 2.0 + top_h + name_h), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, &p.name)); if !ui.is_rect_visible(full) { return; } @@ -681,7 +696,7 @@ fn group_header(ui: &mut egui::Ui, label: &str, key: &str, folder: &[String], op let font = if folder.is_empty() { theme::semibold(12.0) } else { theme::medium(12.0) }; ui.painter().text(pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, shown, font, t.text); ui.painter().text(pos2(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, count.to_string(), theme::medium(11.0), t.text_faint); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::CollapsingHeader, true, shown)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::DisclosureTriangle, true, shown)); let me = match folder { [] => BrushDrag::Group(key.to_string()), f => BrushDrag::Folder { group: key.to_string(), folder: f.to_vec() }, @@ -799,21 +814,28 @@ fn draw_nodes(ui: &mut egui::Ui, d: &Draw, nodes: &[Node], depth: usize, acts: & } else if d.grid { // Rows of whole columns, each with the same margin: a wrapped row indented only // its first line and left the spare width on the right. - let (left, cols) = grid_columns(ui.available_width(), d.layout.cell, d.layout.indent + indent, GRID_GAP); + let (left, cols) = grid_columns(ui.available_width(), grid_cell_width(d.layout.cell, d.scale), d.layout.indent + indent, GRID_GAP); ui.vertical(|ui| { ui.spacing_mut().item_spacing = vec2(GRID_GAP, GRID_GAP); for row in run.chunks(cols) { ui.horizontal(|ui| { ui.add_space(left); for p in row { - grid_cell(ui, p, d.current.is_some_and(|n| p.name.eq_ignore_ascii_case(n)), d.presets, d.layout.cell, acts); + grid_cell( + ui, + p, + d.current.is_some_and(|n| p.name.eq_ignore_ascii_case(n)), + d.presets, + grid_cell_width(d.layout.cell, d.scale), + acts, + ); } }); } }); } else { for p in run.iter() { - list_row(ui, p, d.current.is_some_and(|n| p.name.eq_ignore_ascii_case(n)), d.presets, indent, acts); + list_row(ui, p, d.current.is_some_and(|n| p.name.eq_ignore_ascii_case(n)), d.presets, indent, d.scale, acts); } } run.clear(); @@ -844,8 +866,10 @@ fn rename_bar(ui: &mut egui::Ui, st: &mut BrushesPanelState, acts: &mut Vec folder.last().cloned().unwrap_or_default(), }; - egui::Area::new(egui::Id::new(("brush-drag-label", layout.id))).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show( + egui::Area::new(egui::Id::unique(("brush-drag-label", layout.id))).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show( ui.ctx(), |ui| { if matches!(&*d, BrushDrag::Preset(_)) { @@ -980,7 +1004,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { r }); let mut s = b.size; - if widgets::value_field(ui, &mut s, 1.0..=MAX_BRUSH_SIZE, "px", 74.0).changed() { + if widgets::value_field(ui, &mut s, 1.0..=MAX_BRUSH_SIZE, "px", 74.0).accessible_name(tl!("Size")).changed() { b.size = s.round().clamp(1.0, MAX_BRUSH_SIZE); } }); @@ -990,7 +1014,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint); ui.add_space(20.0); let search_w = (inner_w - 112.0).clamp(90.0, inner_w - 40.0); - widgets::text_field(ui, ui.id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), search_w, false); + widgets::text_field(ui, ui.scope_id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), search_w, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let view = &mut app.ui.brushes_panel.view; let grid_tip = "Grid view"; @@ -1003,6 +1027,20 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); }); + // The size of the previews in the list below (Photoshop's Brushes panel slider), apart from the + // Size of the current brush above. + ui.horizontal(|ui| { + icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "image", 14.0, t.text_faint); + ui.add_space(20.0); + let w = (inner_w - 112.0).clamp(90.0, inner_w - 40.0); + ui.allocate_ui_with_layout(vec2(w, 18.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + let scale = &mut app.ui.brushes_panel.scale; + let resp = widgets::slider(ui, scale, PREVIEW_SCALE_RANGE, None).on_hover_text(tl!("Preview size")); + if resp.changed() { + *scale = (*scale * 100.0).round() / 100.0; + } + }); + }); ui.add_space(6.0); let acts = preset_list(ui, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), &mut app.ui.brushes_panel, responsive_list_layout(ui)); apply(app, acts); diff --git a/crates/ui-egui/src/brushes_tab_tests.rs b/crates/ui-egui/src/brushes_tab_tests.rs index 0d0a8b790f..85bf63d38e 100644 --- a/crates/ui-egui/src/brushes_tab_tests.rs +++ b/crates/ui-egui/src/brushes_tab_tests.rs @@ -262,7 +262,7 @@ fn brush_picker_menu_closes_when_a_slider_or_the_grip_is_grabbed() { h.state_mut().ui.tool = crate::state::Tool::Brush; h.state_mut().ui.brush_picker = Some([300.0, 120.0]); h.run_steps(8); - let picker = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("the picker is shown"); + let picker = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-brush-picker"))).expect("the picker is shown"); assert!(!egui::Popup::is_any_open(&h.ctx), "nothing open to start with"); click_gear(&h); h.run_steps(2); @@ -312,7 +312,7 @@ fn brush_picker_upper_controls_stop_at_their_max_width() { h.state_mut().ui.brush_picker = Some([300.0, 120.0]); h.state_mut().ui.brush_picker_size = Some([500.0, 420.0]); h.run_steps(8); - let picker = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("the picker is shown"); + let picker = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-brush-picker"))).expect("the picker is shown"); let size_label = h.query_all_by_label(crate::i18n::t("Size")).find(|n| picker.contains(n.rect().center())).expect("the picker's Size label").rect(); // The Size slider's middle: 12 pt below the label's (the row stands as tall as its 24 pt value // field, the label centred in it), the style's spacing, then half the slider's 18 pt — 27 pt @@ -399,3 +399,39 @@ fn card_tips_never_cover_their_size_number() { assert!(r.min.is_finite() && r.max.is_finite(), "{n} {size} {scale}: {r:?}"); } } + +/// #7256: the Brushes tab's preview slider scales the grid cells and list rows; it is not the +/// Size of the current brush. +#[test] +fn the_preview_slider_scales_the_previews_not_the_brush_size() { + for grid in [true, false] { + let mut sizes = Vec::new(); + for scale in [0.5f32, 1.0, 2.0] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.ui.panels.brush_settings = true; + app.ui.brush_tab = 1; + app.ui.brushes_panel.view = if grid { BrushesView::Grid } else { BrushesView::List }; + app.ui.brushes_panel.scale = scale; + let size = app.session.tools.brush.size; + let name = app.session.tools.presets[0].name.clone(); + let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + app + }); + h.run_steps(4); + let r = h.get_by_role_and_label(Role::Button, &name).rect(); + sizes.push(if grid { r.width() } else { r.height() }); + assert_eq!(h.state().session.tools.brush.size, size, "the preview slider leaves the brush size alone"); + } + assert!(sizes[0] < sizes[1] && sizes[1] < sizes[2], "grid {grid}: previews grow with the slider: {sizes:?}"); + } +} + +#[test] +fn preview_scale_is_clamped_to_its_range() { + assert_eq!(list_row_height(1.0), 44.0); + assert_eq!(list_row_height(100.0), 88.0); + assert_eq!(list_row_height(f32::NAN), 44.0); + assert_eq!(grid_cell_width(52.0, 0.0), 26.0); + assert_eq!(grid_cell_width(52.0, f32::INFINITY), 52.0); +} diff --git a/crates/ui-egui/src/caf_ui.rs b/crates/ui-egui/src/caf_ui.rs index 149474b260..aaa7978e23 100644 --- a/crates/ui-egui/src/caf_ui.rs +++ b/crates/ui-egui/src/caf_ui.rs @@ -1243,7 +1243,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut reset_what: Option<&str> = None; let mut rebase_needed = false; let mut dragging = false; - egui::Area::new(egui::Id::new("caf-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("caf-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(w) = app.distort.caf.as_mut() else { return }; let before = w.opts.clone(); w.texture_limit = ctx.input(|i| i.max_texture_side); @@ -1279,7 +1279,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { bui.add_space(8.0); bui.label(tl!("Size:")); let mut size = w.brush_size as f32; - if widgets::value_field(&mut bui, &mut size, 1.0..=5000.0, " px", 64.0).changed() { + if widgets::value_field(&mut bui, &mut size, 1.0..=5000.0, " px", 64.0).accessible_name(tl!("Size:")).changed() { w.brush_size = f64::from(size); } } @@ -1303,7 +1303,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if widgets::secondary_button(&mut bui, tl!("Contract"), 70.0).clicked() { fill_btn = Some(json!({"op": "contract", "by": w.expand_by})); } - widgets::value_field(&mut bui, &mut w.expand_by, 1.0..=100.0, " px", 56.0); + widgets::value_field(&mut bui, &mut w.expand_by, 1.0..=100.0, " px", 56.0).accessible_name(tl!("Expand")); if crate::icons::button(&mut bui, "undo-2", 26.0, false, tl!("Reset")).clicked() { reset_what = Some("lasso"); } @@ -1338,7 +1338,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::color_edit_button_srgb(ui, &mut w.opts.color); ui.add_space(8.0); ui.label(tl!("Indicates")); - widgets::dropdown(ui, "caf-indicates", &mut w.opts.excluded, &[(false, tl!("Sampling Area")), (true, tl!("Excluded Area"))], 120.0); + widgets::dropdown( + ui, + "caf-indicates", + tl!("Indicates"), + &mut w.opts.excluded, + &[(false, tl!("Sampling Area")), (true, tl!("Excluded Area"))], + 120.0, + ); }); }); widgets::hairline(ui); @@ -1377,7 +1384,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ("high".to_string(), tl!("High")), ("veryHigh".to_string(), tl!("Very High")), ]; - widgets::dropdown(ui, "caf-color", &mut w.opts.color_adaptation, &o, 110.0); + widgets::dropdown(ui, "caf-color", tl!("Color Adaptation"), &mut w.opts.color_adaptation, &o, 110.0); }); ui.horizontal(|ui| { ui.label(tl!("Rotation Adaptation")); @@ -1388,7 +1395,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ("high".to_string(), tl!("High")), ("full".to_string(), tl!("Full")), ]; - widgets::dropdown(ui, "caf-rotation", &mut w.opts.rotation, &o, 110.0); + widgets::dropdown(ui, "caf-rotation", tl!("Rotation Adaptation"), &mut w.opts.rotation, &o, 110.0); }); ui.horizontal(|ui| { widgets::checkbox(ui, &mut w.opts.scale, tl!("Scale")); @@ -1400,7 +1407,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.label(tl!("Output To")); let o = [("current".to_string(), tl!("Current Layer")), ("new".to_string(), tl!("New Layer")), ("duplicate".to_string(), tl!("Duplicate Layer"))]; - widgets::dropdown(ui, "caf-output", &mut w.opts.output, &o, 140.0); + widgets::dropdown(ui, "caf-output", tl!("Output To"), &mut w.opts.output, &o, 140.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)); @@ -1453,7 +1460,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let sp = ERect::from_min_size(pv_area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); ui.put(sp, egui::Spinner::new().size(16.0)); } - let presp = ui.interact(pv_area, egui::Id::new("caf-preview-view"), Sense::click_and_drag()); + let presp = ui.interact(pv_area, egui::Id::unique("caf-preview-view"), Sense::click_and_drag()); if presp.dragged() { let d = presp.drag_delta(); w.preview.center = [w.preview.center[0] - f64::from(d.x / pv.zoom), w.preview.center[1] - f64::from(d.y / pv.zoom)]; @@ -1467,11 +1474,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let zrow = ERect::from_min_size(pos2(pv_rect.left() + 10.0, pv_rect.bottom() - 38.0), vec2(PREVIEW_W - 20.0, 30.0)); let mut zr = ui.new_child(egui::UiBuilder::new().max_rect(zrow).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut pct = (w.preview.zoom * 100.0).round(); - if widgets::value_field(&mut zr, &mut pct, 1.0..=3200.0, "%", 64.0).changed() { + if widgets::value_field(&mut zr, &mut pct, 1.0..=3200.0, "%", 64.0).accessible_name(tl!("Zoom")).changed() { w.preview.zoom = pct / 100.0; } let mut lz = pct.max(1.0).ln(); - if zr.add(egui::Slider::new(&mut lz, 0.0..=3200f32.ln()).show_value(false)).changed() { + if zr.add(egui::Slider::new(&mut lz, 0.0..=3200f32.ln()).show_value(false)).accessible_name(tl!("Zoom")).changed() { w.preview.zoom = lz.exp() / 100.0; } // The document view. @@ -1481,7 +1488,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if w.zoom <= 0.0 { w.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / chh).clamp(0.01, 4.0); } - let resp = ui.interact(area, egui::Id::new("caf-view"), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique("caf-view"), Sense::click_and_drag()); if resp.hovered() { let scroll = ui.input(|i| i.smooth_scroll_delta.y); if scroll != 0.0 @@ -1640,6 +1647,31 @@ mod tests { app.distort.caf.as_ref().unwrap() } + #[test] + fn settings_dropdowns_are_named_for_their_visible_labels() { + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(1000.0, 900.0)) + .with_context_setup(|ctx| PhotocraftApp::setup_context(ctx, crate::theme::ThemeKind::ALL[0])) + .build_ui_state(|ui, app: &mut PhotocraftApp| show(app, ui.ctx()), app); + h.run_steps(2); + // The Indicates dropdown lives inside a collapsed "Sampling Area Overlay" header. + h.get_by_role_and_label(Role::DisclosureTriangle, "Sampling Area Overlay").click(); + h.run_steps(3); + let labels: Vec = h.query_all_by_role(Role::ComboBox).filter_map(|n| n.accesskit_node().label()).collect(); + for label in ["Indicates", "Color Adaptation", "Rotation Adaptation", "Output To"] { + assert!(labels.iter().any(|have| have == label), "{label} names its dropdown: {labels:?}"); + } + for wrong in ["Contents:", "Color", "Rotation", "Source"] { + assert!(!labels.iter().any(|have| have == wrong), "{wrong} names no control here: {labels:?}"); + } + } + /// Photoshop opens the workspace with the Sampling Brush subtracting at a tenth of the /// window's side, Auto sampling, output to a new layer. #[test] diff --git a/crates/ui-egui/src/cam_preview.rs b/crates/ui-egui/src/cam_preview.rs index 45f6c5f1ed..68cd29f02c 100644 --- a/crates/ui-egui/src/cam_preview.rs +++ b/crates/ui-egui/src/cam_preview.rs @@ -29,6 +29,9 @@ struct Running { key: String, ctx: JobCtx, rx: Receiver>, + /// Test-only notification sent after this worker publishes its real result. + #[cfg(test)] + finished: Receiver<()>, /// The full-resolution job that follows a coarse one. refine: Option, /// This job refines a coarse preview already shown: its failure leaves that one shown. @@ -179,13 +182,25 @@ fn spawn(job: ContentAwarePreview, key: String, refining: bool) -> Option BASE && showing(&app)); assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30), "the preview shows the move"); let doc = app.session.active().unwrap().doc.clone(); @@ -270,32 +277,37 @@ mod tests { tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m); tool_event(&mut app, ToolEvent::Up { x: 72.0, y: 30.0 }, m); assert!(crate::cam_transform::active(&app)); - let (mut now, mut shown) = (0.0, step(&mut app, 0.0)); - for _ in 0..800 { - if shown.is_some() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - now += 10.0; - shown = step(&mut app, now); - } - let (preview, _) = shown.expect("the box shows the result"); + let mut now = 0.0; + let (preview, _) = wait_for(&mut app, &mut now, 0).expect("the box shows the result"); assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30)); // Cancelling the box drops the preview. crate::cam_transform::cancel(&mut app); assert!(step(&mut app, now + 1.0).is_none() && app.cam_preview.is_none()); } - /// Step until the canvas shows a preview with a key past `after`, or give up after a while. + /// Wait for the specific worker to publish its result, then let the normal non-blocking + /// `step` path consume it. Coverage instrumentation can make a solve much slower than a + /// fixed polling loop; the watchdog bounds a stuck worker without setting its expected speed. fn wait_for(app: &mut PhotocraftApp, now: &mut f64, after: u64) -> Option<(Arc, u64)> { - for _ in 0..6000 { - if let Some(shown) = step(app, *now).filter(|s| s.1 > after) { - return Some(shown); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); + loop { + let shown = step(app, *now); + if shown.as_ref().is_some_and(|s| s.1 > after) { + return shown; + } + let p = app.cam_preview.as_ref()?; + if let Some(running) = &p.running { + let remaining = deadline.checked_duration_since(std::time::Instant::now())?; + running.finished.recv_timeout(remaining).ok()?; + } else if shown.is_some() || p.failed.as_deref() == Some(p.key.as_str()) { + return None; + } else if *now < p.since + DEBOUNCE_MS { + // Advance the simulated debounce clock without sleeping in real time. + *now = p.since + DEBOUNCE_MS; + } else { + return None; } - std::thread::sleep(std::time::Duration::from_millis(10)); - *now += 10.0; } - None } #[test] diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs index 995645940e..1914dc883a 100644 --- a/crates/ui-egui/src/cam_transform.rs +++ b/crates/ui-egui/src/cam_transform.rs @@ -210,10 +210,7 @@ pub(crate) fn cursor(app: &PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p Some([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]) }; Some(match h { - Hit::Corner(i) => { - let (a, b) = (q.get(i)?, q.get((i + 2) % 4)?); - crate::tool_cursor::resize([a[0] - b[0], a[1] - b[1]], xf) - } + Hit::Corner(i) => crate::tool_cursor::corner_resize(&q, i, xf), Hit::Edge(i) => { let (a, b) = (mid(i)?, mid(i + 2)?); crate::tool_cursor::resize([a[0] - b[0], a[1] - b[1]], xf) diff --git a/crates/ui-egui/src/camera_raw_detail_ui.rs b/crates/ui-egui/src/camera_raw_detail_ui.rs index 7b445f1775..88747a8dd7 100644 --- a/crates/ui-egui/src/camera_raw_detail_ui.rs +++ b/crates/ui-egui/src/camera_raw_detail_ui.rs @@ -22,6 +22,9 @@ type OverlayKey = (u64, bool, PixelRect, crate::camera_raw_scope_ui::ClippingMod pub(crate) struct DetailPreview { source: Surface, + /// The open-time dialog's camera tone curve: Shadows, Highlights, Clarity, Texture and Dehaze + /// then act on the raw data, in linear light before it (see [`develop`]). + pub(crate) raw_curve: Option>>, area: PixelRect, coverage: Coverage, result: Option<(u64, Surface)>, @@ -46,6 +49,7 @@ impl DetailPreview { pub(crate) fn new(source: Surface, area: PixelRect, coverage: Coverage) -> Self { Self { source, + raw_curve: None, area, coverage, result: None, @@ -105,6 +109,7 @@ impl DetailPreview { return; } let source = self.source.clone(); + let raw_curve = self.raw_curve.clone(); let area = self.area; let coverage = self.coverage.clone(); let mut params = params.clone(); @@ -116,9 +121,10 @@ impl DetailPreview { if cancel.load(Ordering::Relaxed) { return; } - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| develop(&source, area, ¶ms, &coverage))) - .map(|surface| (revision, surface)) - .map_err(|_| "Camera Raw detail preview failed; the proxy remains available".into()); + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| develop(&source, raw_curve.as_deref().map(Vec::as_slice), area, ¶ms, &coverage))) + .map(|surface| (revision, surface)) + .map_err(|_| "Camera Raw detail preview failed; the proxy remains available".into()); if !cancel.load(Ordering::Relaxed) { let _ = tx.send(result); ctx.request_repaint(); @@ -291,8 +297,53 @@ impl DetailPreview { } } -fn develop(source: &Surface, area: PixelRect, params: &CameraRaw, coverage: &Coverage) -> Surface { - let mut result = photocraft_engine::lens_cmds::camera_raw_surface(source, area, params); +/// The full-size result of `params` over `area`. With a raw file's tone `curve`, the raw-stage +/// sliders are applied first to the whole area, from the whole frame's maps as Open computes them +/// ([`photocraft_io::raw::retone`]), then the rest of the pipeline: at 1:1 the preview shows close +/// to what Open will make. It retones the developed pixels, which already carry the black point, +/// so it differs from Open only in the deepest shadows, as `retone` documents. +fn develop(source: &Surface, curve: Option<&[[f32; 2]]>, area: PixelRect, params: &CameraRaw, coverage: &Coverage) -> Surface { + let tone = photocraft_io::raw::Tone { + shadows: params.shadows, + highlights: params.highlights, + clarity: params.clarity, + texture: params.texture, + dehaze: params.dehaze, + }; + let (retoned, params) = match curve { + Some(curve) if !tone.is_neutral() => { + let (w, h) = (area.width() as usize, area.height() as usize); + let mut px = vec![[0.0f32; 4]; w * h]; + source.read_rgba_into(area, &mut px); + photocraft_io::raw::retone(&mut px, w, h, curve, tone); + let fmt = source.format(); + let n = fmt.channels(); + // Written back in bands of rows, so only one band-sized buffer lives next to `px` + // (a whole-area one is another 4 bytes per channel and pixel). Each band is read from + // the source first to keep its stored alpha. + let mut out = source.clone(); + let band = (65_536 / w.max(1)).max(1); + let mut data = Vec::new(); + for (b, rows) in px.chunks(band * w.max(1)).enumerate() { + let y0 = area.y0 + (b * band) as i32; + let rect = PixelRect::new(area.x0, y0, area.x1, y0 + (rows.len() / w.max(1)) as i32); + source.read_region_into(rect, &mut data); + for (o, q) in data.chunks_exact_mut(n).zip(rows) { + let alpha = if fmt.alpha { o.get(n - 1).copied() } else { None }; + photocraft_raster::from_rgba_into(&fmt, *q, o); + if let (Some(a), Some(last)) = (alpha, o.get_mut(n - 1)) { + *last = a; + } + } + out.write_region(rect, &data); + } + let rest = CameraRaw { shadows: 0.0, highlights: 0.0, clarity: 0.0, texture: 0.0, dehaze: 0.0, ..params.clone() }; + (Some(out), rest) + } + _ => (None, params.clone()), + }; + let base = retoned.as_ref().unwrap_or(source); + let mut result = photocraft_engine::lens_cmds::camera_raw_surface(base, area, ¶ms); if matches!(coverage, Coverage::None) { return result; } @@ -403,10 +454,47 @@ mod tests { source.write_region(area, &values); let params = CameraRaw { exposure: 0.5, texture: 15.0, grain_amount: 30.0, ..Default::default() }; let expected = photocraft_engine::lens_cmds::camera_raw_surface(&source, area, ¶ms); - assert_eq!(develop(&source, area, ¶ms, &Coverage::None), expected); + assert_eq!(develop(&source, None, area, ¶ms, &Coverage::None), expected); let mask = Surface::with_default(PixelFormat::GRAY8, &[0.0]); - assert_eq!(develop(&source, area, ¶ms, &Coverage::Selection(mask)), source); + assert_eq!(develop(&source, None, area, ¶ms, &Coverage::Selection(mask)), source); } + #[test] + fn a_raw_open_refines_raw_stage_sliders_from_the_whole_frame() { + // Shadows on a raw being opened: the full-size refinement retones the whole frame (as + // Open does) and runs the rest of the pipeline after, instead of the filter's Shadows. + let area = PixelRect::new(0, 0, 96, 64); + let mut source = Surface::new(PixelFormat::RGBA16); + let mut values = vec![0.0; 96 * 64 * 4]; + for (i, p) in values.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let v = if (i % 96) < 48 { 0.12 } else { 0.7 }; + p.copy_from_slice(&[v, v * 0.9, v * 0.8, 1.0]); + } + source.write_region(area, &values); + let curve = [[0.01, 0.01], [0.05, 0.12], [0.3, 0.6], [1.0, 1.0]]; + let params = CameraRaw { shadows: 80.0, exposure: 0.25, ..Default::default() }; + let refined = develop(&source, Some(&curve), area, ¶ms, &Coverage::None); + let mut px = vec![[0.0f32; 4]; 96 * 64]; + source.read_rgba_into(area, &mut px); + let tone = photocraft_io::raw::Tone { shadows: 80.0, ..Default::default() }; + photocraft_io::raw::retone(&mut px, 96, 64, &curve, tone); + let mut retoned = source.clone(); + let flat: Vec = px.iter().flatten().copied().collect(); + retoned.write_region(area, &flat); + let expected = photocraft_engine::lens_cmds::camera_raw_surface(&retoned, area, &CameraRaw { exposure: 0.25, ..Default::default() }); + let (a, b) = (refined.rgba(10, 30), expected.rgba(10, 30)); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 2e-4), "{a:?} vs {b:?}"); + assert!( + refined.rgba(10, 30)[0] > source.rgba(10, 30)[0] + 0.02, + "the shadow side is lifted: {:?} from {:?}, retoned {:?}", + refined.rgba(10, 30), + source.rgba(10, 30), + retoned.rgba(10, 30) + ); + // Without a raw curve Shadows is the filter's. + let filter = develop(&source, None, area, ¶ms, &Coverage::None); + assert_eq!(filter, photocraft_engine::lens_cmds::camera_raw_surface(&source, area, ¶ms)); + } + #[test] fn stale_refinements_and_failed_old_revisions_do_not_replace_new_settings() { let source = Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.4, 0.6, 1.0]); @@ -446,7 +534,7 @@ mod tests { } source.write_region(area, &pixels); let params = CameraRaw { exposure: 0.5, sharpen_amount: 20.0, grain_amount: 30.0, ..Default::default() }; - let full = develop(&source, area, ¶ms, &Coverage::None); + let full = develop(&source, None, area, ¶ms, &Coverage::None); let expected = photocraft_engine::lens_cmds::camera_raw_surface(&source, area, ¶ms); assert_eq!(full, expected); let mut preview = DetailPreview::new(source, area, Coverage::None); diff --git a/crates/ui-egui/src/camera_raw_gpu.rs b/crates/ui-egui/src/camera_raw_gpu.rs new file mode 100644 index 0000000000..5e9f8719fa --- /dev/null +++ b/crates/ui-egui/src/camera_raw_gpu.rs @@ -0,0 +1,294 @@ +//! Camera Raw's preview develop on the GPU (#6916). When every stage the settings use reads +//! only its own pixel, the proxy develops in one wgpu pass (`photocraft_gpu::CameraRawDevelop`) +//! on the canvas's device and is read back for the histogram, scopes and display conversion. +//! Spatial stages, Rendering Mode CPU, `PHOTOCRAFT_CPU_CAMERA_RAW`, no GPU canvas, a software +//! adapter, a device fault or a failed develop all keep the CPU develop on the worker. +//! +//! The pipeline builds on a thread the first time a dialog opens (DX12's shader compiler takes +//! about 0.35 s, #6419); frames develop on the CPU until it is ready. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use eframe::wgpu; +use photocraft_algo::camera_raw::CameraRaw; +use photocraft_gpu::{CameraRawDevelop, DeviceHealth}; + +use crate::PhotocraftApp; + +/// The built pass, shared by every Camera Raw dialog on the device (never built on the web). +#[derive(Clone)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) struct Pass(Arc>); + +/// A device's pass: unset while it builds, `None` when the device can't run it. +pub(crate) type Slot = Arc>>; + +impl Pass { + /// Whether `adapter` may run the pass: not a software adapter (the CPU develop is faster + /// there), one that renders `Rgba32Float`, and not on the web (no blocking readback yet). + fn usable(adapter: &wgpu::Adapter) -> bool { + if cfg!(target_arch = "wasm32") { + return false; + } + if adapter.get_info().device_type == wgpu::DeviceType::Cpu { + log::info!("Camera Raw develops on the CPU: software graphics adapter"); + return false; + } + if !CameraRawDevelop::supported(adapter) { + log::info!("Camera Raw develops on the CPU: the adapter can't render Rgba32Float"); + return false; + } + true + } + + /// Builds the pass into `slot`, on a thread where there are threads. + pub(crate) fn start(adapter: &wgpu::Adapter, device: &wgpu::Device, slot: &Slot) { + if !Self::usable(adapter) { + let _ = (slot.set(None), device); + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let (on, out) = (device.clone(), slot.clone()); + let spawned = std::thread::Builder::new().name("camera-raw-pipeline".into()).spawn(move || { + let pass = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::build_on(&on))).unwrap_or_else(|_| { + log::warn!("building the Camera Raw pipeline panicked; Camera Raw develops on the CPU"); + None + }); + let _ = out.set(pass); + }); + if spawned.is_err() { + let _ = slot.set(Self::build_on(device)); + } + } + } + + /// The pass on `device`, whatever its adapter. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(crate) fn build_on(device: &wgpu::Device) -> Option { + match CameraRawDevelop::new(device) { + Ok(pass) => Some(Pass(Arc::new(Mutex::new(pass)))), + Err(e) => { + log::warn!("{e}; Camera Raw develops on the CPU"); + None + } + } + } +} + +/// The dialog's GPU develop, if the app may use one. +pub(crate) fn for_app(app: &PhotocraftApp) -> Option { + let cpu = app.session.prefs().performance.effective_rendering_mode() == photocraft_engine::prefs::RenderingMode::Cpu; + if cpu || std::env::var_os("PHOTOCRAFT_CPU_CAMERA_RAW").is_some() { + return None; + } + app.gpu.as_ref()?.camera_raw() +} + +/// A key for a dialog's proxy pixels, unique in the process: the pass keeps the last source it +/// uploaded, and a new dialog's proxy may reuse a closed one's memory. +pub(crate) fn source_key() -> u64 { + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +/// The source key and the settings the black and white points depend on. +type PointsKey = (u64, [u32; 5]); +/// The last [`PointsKey`] and its points. +type Points = Option<(PointsKey, Option<(f32, f32)>)>; + +/// One dialog's handle on the pass (cheap to clone into each proxy job). +#[derive(Clone)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) struct CameraRawGpu { + device: wgpu::Device, + queue: wgpu::Queue, + health: DeviceHealth, + pass: Slot, + /// Set after a develop failed: the dialog stays on the CPU. + failed: Arc, + /// Whites and Blacks adapt to the image; its points change only with these settings. + points: Arc>, +} + +impl CameraRawGpu { + pub(crate) fn new(device: wgpu::Device, queue: wgpu::Queue, health: DeviceHealth, pass: Slot) -> Self { + CameraRawGpu { device, queue, health, pass, failed: Arc::default(), points: Arc::default() } + } + + /// Develops `px` (`w × h`) with `p`, or `None` when the CPU must: a spatial stage, the pass + /// still building, a fault, or a GPU error (logged; the dialog then stays on the CPU). `key` names the pixels when they + /// are the dialog's unchanged proxy ([`source_key`]), so they upload once. Neutral settings + /// are `None` too: the CPU develop returns them at once, a GPU round trip would only cost. + pub(crate) fn develop(&self, px: &[[f32; 4]], key: Option, w: usize, h: usize, p: &CameraRaw, float: bool) -> Option> { + if p.spatial() || p.is_identity() || self.failed.load(Ordering::Relaxed) || !self.health.is_ok() { + return None; + } + #[cfg(target_arch = "wasm32")] + { + let _ = (px, key, w, h, float); + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let Some(Some(pass)) = self.pass.get() else { return None }; + let points = self.points(px, key, w, h, p); + let stages = photocraft_algo::camera_raw::per_pixel(p, w, h, float, points)?; + let size = [u32::try_from(w).ok()?, u32::try_from(h).ok()?]; + let mut pass = pass.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + match pass.develop(&self.device, &self.queue, Some(&self.health), px, key, size, &stages) { + Ok(out) => Some(out), + Err(e) => { + log::warn!("{}; Camera Raw develops on the CPU", e.0); + self.failed.store(true, Ordering::Relaxed); + None + } + } + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn points(&self, px: &[[f32; 4]], key: Option, w: usize, h: usize, p: &CameraRaw) -> Option<(f32, f32)> { + if p.whites == 0.0 && p.blacks == 0.0 { + return None; + } + let Some(key) = key else { return photocraft_algo::camera_raw::white_black_points(px, w, h, p) }; + let key = (key, [p.temperature, p.tint, p.exposure, p.contrast, p.encoding_gamma].map(f32::to_bits)); + let mut cache = self.points.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((k, points)) = *cache + && k == key + { + return points; + } + let points = photocraft_algo::camera_raw::white_black_points(px, w, h, p); + *cache = Some((key, points)); + points + } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_algo::camera_raw::develop; + use serde_json::json; + + /// The pass evaluates develop's expressions in f32 (see `photocraft-gpu`'s Camera Raw tests). + const TOL: f32 = 1.0 / 4096.0; + + /// The app on egui_kittest's device (a software adapter where there is one), or `None`. + fn gpu_app(depth: u32) -> Option<(PhotocraftApp, egui::Context)> { + let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else { + eprintln!("skipping: no GPU adapter"); + return None; + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.gpu.as_ref()?; + app.run("file.new", json!({"width": 300, "height": 200, "depth": depth})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("filter.render.clouds", json!({"seed": 5})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 30, "width": 120, "height": 90})).unwrap(); + app.run("edit.fill", json!({"color": "#cc4422"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + Some((app, egui::Context::default())) + } + + /// The canvas's pass, built even on a software adapter (which the app leaves on the CPU). + fn force_pass(app: &PhotocraftApp) { + app.gpu.as_ref().expect("GPU canvas").force_camera_raw_pass(); + } + + fn developed(app: &mut PhotocraftApp, ctx: &egui::Context, p: CameraRaw) -> (&'static str, Vec<[f32; 4]>, Vec<[f32; 4]>) { + let d = app.camera_raw.as_mut().expect("dialog"); + d.params = p; + d.render(ctx); + let mut cpu = d.proxy.to_vec(); + let mut q = d.params.clone(); + q.pixel_scale = (d.pw as f32 / d.full_w.max(1) as f32).min(1.0); + develop(&mut cpu, d.pw, d.ph, &q, d.float); + (d.develop_backend, d.processed.clone(), cpu) + } + + #[test] + fn the_pass_is_built_when_camera_raw_first_opens_not_with_the_canvas() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some((mut app, ctx)) = gpu_app(8) else { return }; + let canvas = app.gpu.clone().expect("GPU canvas"); + assert!(!canvas.camera_raw_started(), "no Camera Raw pipeline at launch (#6419)"); + crate::camera_raw_ui::open(&mut app, &ctx).unwrap(); + assert!(canvas.camera_raw_started(), "built (or refused) from the first open"); + assert_eq!(app.camera_raw.as_ref().map(|d| d.develop_backend), Some("cpu"), "the first frame doesn't wait for the pipeline"); + canvas.wait_camera_raw_pass(); + let software = canvas.adapter_info().device_type == wgpu::DeviceType::Cpu; + let d = app.camera_raw.as_ref().expect("dialog"); + assert_eq!(d.gpu.is_none(), software, "a software adapter keeps the CPU develop"); + } + + #[test] + fn per_pixel_settings_develop_on_the_gpu_like_the_cpu_and_the_rest_stays_on_the_cpu() { + let _gpu = photocraft_testkit::gpu_lock(); + for depth in [8, 16, 32] { + let Some((mut app, ctx)) = gpu_app(depth) else { return }; + force_pass(&app); + crate::camera_raw_ui::open(&mut app, &ctx).unwrap(); + if app.camera_raw.as_ref().is_some_and(|d| d.gpu.is_none()) { + eprintln!("skipping: the device can't run the Camera Raw pass"); + return; + } + let per_pixel = CameraRaw { + temperature: 20.0, + exposure: 0.4, + contrast: 30.0, + whites: 25.0, + blacks: -20.0, + vibrance: 40.0, + curve_lights: 25.0, + grade_shadows: photocraft_algo::camera_raw::Wheel { hue: 220.0, sat: 40.0, lum: 0.0 }, + vignette_amount: -40.0, + grain_amount: 30.0, + ..Default::default() + }; + let (backend, gpu, cpu) = developed(&mut app, &ctx, per_pixel.clone()); + assert_eq!(backend, "gpu", "{depth}-bit"); + let worst = gpu.iter().zip(&cpu).flat_map(|(a, b)| (0..4).map(move |c| (a[c] - b[c]).abs())).fold(0.0f32, f32::max); + assert!(worst <= TOL, "{depth}-bit: off by {worst}"); + let described = app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap(); + assert_eq!(described["developBackend"], "gpu"); + // A spatial stage develops the whole frame on the CPU. + let (backend, shown, cpu) = developed(&mut app, &ctx, CameraRaw { shadows: 40.0, ..per_pixel.clone() }); + assert_eq!((backend, shown == cpu), ("cpu", true), "{depth}-bit"); + // A device fault: the CPU, from the next frame on. + app.gpu.as_ref().unwrap().health().mark(photocraft_gpu::Fault::Lost("test".into())); + let (backend, shown, cpu) = developed(&mut app, &ctx, per_pixel); + assert_eq!((backend, shown == cpu), ("cpu", true), "{depth}-bit after a fault"); + } + } + + #[test] + fn neutral_settings_skip_the_gpu_round_trip() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some((mut app, ctx)) = gpu_app(8) else { return }; + force_pass(&app); + crate::camera_raw_ui::open(&mut app, &ctx).unwrap(); + if app.camera_raw.as_ref().is_some_and(|d| d.gpu.is_none()) { + eprintln!("skipping: the device can't run the Camera Raw pass"); + return; + } + let (backend, _, _) = developed(&mut app, &ctx, CameraRaw { exposure: 0.5, ..Default::default() }); + assert_eq!(backend, "gpu", "a per-pixel setting develops on the GPU"); + let (backend, shown, cpu) = developed(&mut app, &ctx, CameraRaw::default()); + assert_eq!((backend, shown == cpu), ("cpu", true), "default settings: the CPU returns them unchanged"); + } + + #[test] + fn rendering_mode_cpu_keeps_the_cpu_develop() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some((mut app, ctx)) = gpu_app(8) else { return }; + force_pass(&app); + app.run("prefs.set", json!({"path": "performance.renderingMode", "value": "cpu"})).unwrap(); + crate::camera_raw_ui::open(&mut app, &ctx).unwrap(); + let (backend, shown, cpu) = developed(&mut app, &ctx, CameraRaw { exposure: 0.5, ..Default::default() }); + assert_eq!((backend, shown == cpu), ("cpu", true)); + } +} diff --git a/crates/ui-egui/src/camera_raw_identity_tests.rs b/crates/ui-egui/src/camera_raw_identity_tests.rs new file mode 100644 index 0000000000..a66e172158 --- /dev/null +++ b/crates/ui-egui/src/camera_raw_identity_tests.rs @@ -0,0 +1,173 @@ +//! Re-develop completion must follow the replacement document across intervening tab changes. + +use std::sync::Arc; + +use serde_json::{Value, json}; + +use super::{CameraRaw, PhotocraftApp, RawOpen, commit_open_raw, finish_redevelop, queue_open_dialog, replace_raw_document}; + +fn synthetic_raw() -> Vec { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let (width, height) = (48, 32); + DngSpec::cfa(width, height, mosaic(&scene(width, height), width, [0, 1, 1, 2], 0, 30000)).build() +} + +struct Control { + doc: Arc, + path: Option, + revision: u64, + saved_revision: u64, + history: usize, +} + +impl Control { + fn assert_untouched(&self, app: &PhotocraftApp) { + let state = app.session.documents().iter().find(|state| state.doc.id == self.doc.id).expect("the accepted control remains open"); + assert!(Arc::ptr_eq(&state.doc, &self.doc), "the RAW completion must not filter another document"); + assert_eq!(state.path, self.path); + assert_eq!((state.revision, state.saved_revision, state.history.past_len()), (self.revision, self.saved_revision, self.history)); + assert!(state.is_dirty(), "the unrelated document still needs saving"); + } +} + +fn identity_app() -> (PhotocraftApp, RawOpen, Control) { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + session.active_mut().unwrap().path = Some("accepted-control.pcraft".into()); + let state = session.active().unwrap(); + let control = Control { + doc: state.doc.clone(), + path: state.path.clone(), + revision: state.revision, + saved_revision: state.saved_revision, + history: state.history.past_len(), + }; + let bytes = Arc::new(synthetic_raw()); + let imported = photocraft_io::import("synthetic.dng", &bytes).unwrap(); + let (index, _) = session.open_document(imported.document, None); + let raw = RawOpen { + document: session.documents()[index].doc.id, + name: "synthetic.dng".into(), + path: None, + bytes: Some(bytes), + developed_with: Default::default(), + demosaic: Default::default(), + }; + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.sync_views(); + (app, raw, control) +} + +/// Run the real DNG worker and apply its replacement, holding the UI completion until after +/// another event changes the tab list. No sleeps, timing races or fake decoder are needed. +fn completed_redevelop(app: &mut PhotocraftApp, raw: &RawOpen) -> (CameraRaw, Value, photocraft_doc::DocId) { + app.background_jobs = true; + let params = CameraRaw { exposure: 1.0, contrast: 20.0, ..Default::default() }; + let started = commit_open_raw(app, ¶ms, raw, None).unwrap(); + let job = photocraft_engine::jobs::JobId(started["job"].as_u64().expect("the sensor re-develop runs as a real job")); + let mut result = app.session.wait_job(job).unwrap(); + let replacement = app.session.documents()[result["document"].as_u64().unwrap() as usize].doc.id; + // The producer has a separate contract test. Supply the observed identity here to isolate + // the UI consumer's handling even when run against the old result schema. + result["docId"] = json!(replacement.0); + app.raw_redevelop.clear(); + app.background_jobs = false; + (params, result, replacement) +} + +#[test] +fn completion_identity_reports_the_live_replacement_after_id_collision() { + let (mut app, raw, control) = identity_app(); + let mut imported = photocraft_io::import("synthetic.dng", &synthetic_raw()).unwrap(); + // Exercise the same collision protection as a persisted native document: the result must + // name the admitted replacement, even when the incoming ID already belongs to a tab. + imported.document.id = control.doc.id; + let result = replace_raw_document(&mut app.session, raw.document, imported, false).unwrap(); + let replacement = app.session.documents()[result["document"].as_u64().unwrap() as usize].doc.id; + assert_ne!(replacement, control.doc.id); + assert_eq!(result["docId"], replacement.0, "the callback needs the session's actual replacement identity"); + control.assert_untouched(&app); +} + +#[test] +fn completion_identity_applies_remaining_settings_after_tab_reordering() { + let (mut app, raw, control) = identity_app(); + let (params, result, replacement) = completed_redevelop(&mut app, &raw); + let index = result["document"].as_u64().unwrap() as usize; + app.session.move_document(index, 0).unwrap(); + assert_eq!(app.session.documents()[index].doc.id, control.doc.id, "another completion may move this tab before the RAW callback"); + finish_redevelop(&mut app, ¶ms, &raw, &result, &Default::default()).unwrap(); + control.assert_untouched(&app); + let state = app.session.active().unwrap(); + assert_eq!(state.doc.id, replacement); + assert!(!state.is_dirty(), "development finishes opening only the replacement"); + assert!(state.history.past_len() > 0, "remaining Camera Raw settings were applied"); + assert!(!app.view_docs.contains(&raw.document)); + assert!(app.view_docs.contains(&replacement)); +} + +#[test] +fn completion_identity_rejects_a_closed_replacement_without_filtering_the_active_control() { + let (mut app, raw, control) = identity_app(); + let (params, result, replacement) = completed_redevelop(&mut app, &raw); + let index = app.session.documents().iter().position(|state| state.doc.id == replacement).unwrap(); + app.session.close(index).unwrap(); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + assert!(finish_redevelop(&mut app, ¶ms, &raw, &result, &Default::default()).is_err(), "a closed replacement must not fall back to the active document"); + control.assert_untouched(&app); + assert_eq!(app.session.documents().len(), 1); +} + +#[test] +fn completion_identity_rejects_missing_and_invalid_ids_before_any_side_effect() { + for identity in [None, Some(Value::Null), Some(json!("invalid")), Some(json!(-1)), Some(json!(u64::MAX)), Some(json!(1.5))] { + let (mut app, raw, control) = identity_app(); + let (params, mut result, _) = completed_redevelop(&mut app, &raw); + result["document"] = json!(0); + match identity { + None => { + result.as_object_mut().unwrap().remove("docId"); + } + Some(value) => result["docId"] = value, + } + app.session.set_active(0); + let views = app.view_docs.clone(); + let notices = app.ui.notices.len(); + assert!(finish_redevelop(&mut app, ¶ms, &raw, &result, &Default::default()).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.view_docs, views, "identity validation precedes view remapping"); + assert_eq!(app.ui.notices.len(), notices, "identity validation precedes import notifications"); + } +} + +#[test] +fn queue_identity_targets_an_inactive_raw_without_changing_the_active_document() { + let (mut app, raw, control) = identity_app(); + app.session.set_active(0); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + queue_open_dialog(&mut app, raw.document, &raw.name, raw.path.as_deref(), raw.bytes.as_deref().map(Vec::as_slice), &[]).unwrap(); + let queued = app.pending_raw_open.as_ref().expect("the explicitly named RAW was queued"); + assert_eq!(queued.document, raw.document); + assert_eq!((&queued.name, &queued.path, &queued.bytes), (&raw.name, &raw.path, &raw.bytes)); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id, "queueing does not activate the target"); + control.assert_untouched(&app); +} + +#[test] +fn queue_identity_rejects_a_missing_document_without_replacing_the_pending_review() { + let (mut app, raw, control) = identity_app(); + queue_open_dialog(&mut app, raw.document, &raw.name, raw.path.as_deref(), raw.bytes.as_deref().map(Vec::as_slice), &[]).unwrap(); + let pending = app.pending_raw_open.as_ref().unwrap().clone(); + app.session.set_active(0); + let missing = photocraft_doc::DocId::fresh(); + assert!(!app.session.documents().iter().any(|state| state.doc.id == missing)); + assert!(queue_open_dialog(&mut app, missing, "missing.dng", Some("missing.dng"), Some(&[1, 2, 3]), &[]).is_err()); + let queued = app.pending_raw_open.as_ref().expect("the existing review is preserved"); + assert_eq!(queued.document, pending.document); + assert_eq!((&queued.name, &queued.path, &queued.bytes), (&pending.name, &pending.path, &pending.bytes)); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + control.assert_untouched(&app); +} diff --git a/crates/ui-egui/src/camera_raw_preview_ui.rs b/crates/ui-egui/src/camera_raw_preview_ui.rs index e2d15f9e0c..dff92fd50e 100644 --- a/crates/ui-egui/src/camera_raw_preview_ui.rs +++ b/crates/ui-egui/src/camera_raw_preview_ui.rs @@ -98,9 +98,16 @@ struct ZoomCapture { /// One capture shared with the colour sampler. Space/middle drag always pans; S takes /// precedence over the persistent Zoom/Hand tool, so a sampler click never zooms. -pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) -> (Rect, Response, bool, Option) { +pub(crate) fn interact( + ui: &mut Ui, + state: &mut CameraRawPreviewState, + viewport: Rect, + size: Vec2, + sampler: &mut bool, + animated_zoom: bool, +) -> (Rect, Response, bool, Option) { let sampling = *sampler; - let response = ui.interact(viewport, ui.id().with("cr-sample-preview"), Sense::click_and_drag()); + let response = ui.interact(viewport, ui.scope_id().with("cr-sample-preview"), Sense::click_and_drag()); let modifiers = ui.input(|i| i.modifiers); let temporary_zoom = modifiers.command && (state.hand || sampling); let hand = !temporary_zoom && (state.hand && !sampling || ui.input(|i| i.key_down(Key::Space))); @@ -111,11 +118,18 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: // Reuse the canvas wheel policy: retain the modifiers of the wheel event while egui // smooths it, even if Alt is released before the final part of the notch arrives. let cr_hovered = response.hovered(); - let wheel = crate::wheel_nav::read(ui.ctx(), false, true, ui.id().with("cr-wheel"), cr_hovered); + let cr_wheel = ui.scope_id().with("cr-wheel"); + let wheel = crate::wheel_nav::read(ui.ctx(), false, true, animated_zoom, cr_wheel, cr_hovered); if cr_hovered { ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); match wheel { - Some(crate::wheel_nav::Wheel::Zoom(factor)) => state.zoom_at(state.scale(viewport, size) * factor, at, viewport, size), + Some(crate::wheel_nav::Wheel::Zoom(factor)) => { + let zoom = state.scale(viewport, size) * factor; + if zoom != zoom.clamp(MIN_ZOOM, MAX_ZOOM) { + crate::wheel_nav::settle(ui.ctx(), cr_wheel); + } + state.zoom_at(zoom, at, viewport, size); + } Some(crate::wheel_nav::Wheel::SnapZoom(n)) => { let dir = if n > 0 { 1 } else { -1 }; let mut nz = state.scale(viewport, size); @@ -236,7 +250,7 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) { let zoom_tool = crate::icons::button(ui, "zoom-in", 28.0, !state.hand && !*sampler, tl!("Zoom Tool")); - zoom_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Zoom Tool"))); + zoom_tool.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Zoom Tool"))); if zoom_tool.double_clicked() { state.fit(); } @@ -245,7 +259,7 @@ pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: *sampler = false; } let hand_tool = crate::icons::button(ui, "hand", 28.0, state.hand && !*sampler, tl!("Hand Tool")); - hand_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Hand Tool"))); + hand_tool.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Hand Tool"))); if hand_tool.double_clicked() { state.fit(); } @@ -269,7 +283,7 @@ pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: if !options.iter().any(|(z, _)| *z == zoom) { options.push((zoom, ¤t_label)); } - if widgets::dropdown(ui, "cr-preview-zoom", &mut zoom, &options, 80.0) { + if widgets::dropdown(ui, "cr-preview-zoom", tl!("Zoom"), &mut zoom, &options, 80.0) { match zoom { Some(z) => state.zoom_at(z, viewport.center(), viewport, size), None => state.fit(), diff --git a/crates/ui-egui/src/camera_raw_scope_ui.rs b/crates/ui-egui/src/camera_raw_scope_ui.rs index 9cb5233d00..daf0a4855f 100644 --- a/crates/ui-egui/src/camera_raw_scope_ui.rs +++ b/crates/ui-egui/src/camera_raw_scope_ui.rs @@ -344,8 +344,8 @@ fn readout_text(value: Option) -> String { fn warning(ui: &mut egui::Ui, rect: Rect, mask: u8, on: &mut bool, label: &str) { let t = Tokens::get(ui.ctx()); - let response = ui.interact(rect, ui.id().with(label), Sense::click()).on_hover_text(label); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, label)); + let response = ui.interact(rect, ui.scope_id().with(label), Sense::click()).on_hover_text(label); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), *on, label)); let r = rect.shrink(3.0); ui.painter().add(egui::Shape::convex_polygon( vec![pos2(r.center().x, r.top()), r.left_bottom(), r.right_bottom()], @@ -381,7 +381,7 @@ fn graph(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeSt let t = Tokens::get(ui.ctx()); let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width().max(1.0), height), Sense::click_and_drag()); d.scope.rect = Some(rect); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), tl!("Tone Histogram"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, ui.is_enabled(), tl!("Tone Histogram"))); ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background()); let plot = rect.shrink(4.0); // Camera Raw's scale: the typical columns set it and a shadow or highlight spike runs off the top. @@ -525,7 +525,7 @@ pub(crate) fn floating(ctx: &egui::Context, d: &mut CameraRawDialog, view: &mut r[1].clamp(content.top(), (content.bottom() - 150.0).max(content.top())), ); let window = egui::Window::new("") - .id(egui::Id::new("cr-floating-scope")) + .id(egui::Id::unique("cr-floating-scope")) .order(egui::Order::Foreground) .collapsible(false) .resizable(true) @@ -619,36 +619,41 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam } else if mode != ClippingMode::None { if d.scope.overlay_key != Some(key) || d.scope.overlay_detail { d.scope.overlay_detail = false; - let colors = d - .pixels() - .iter() - .map(|p| { - let (lo, hi) = clipping(*p); - if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) { - return Color32::TRANSPARENT; - } - match mode { - ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo), - ClippingMode::HighlightChannels => { - if hi == 0 { - t.histogram_background() - } else { - t.histogram_color(hi) - } + let color = |p: &[f32; 4]| { + let (lo, hi) = clipping(*p); + if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) { + return Color32::TRANSPARENT; + } + match mode { + ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo), + ClippingMode::HighlightChannels => { + if hi == 0 { + t.histogram_background() + } else { + t.histogram_color(hi) } - ClippingMode::Warnings(warnings) => { - if warnings & 2 != 0 && hi != 0 { - t.histogram_color(1) - } else if warnings & 1 != 0 && lo != 0 { - t.histogram_color(4) - } else { - Color32::TRANSPARENT - } - } - ClippingMode::None => Color32::TRANSPARENT, } - }) - .collect(); + ClippingMode::Warnings(warnings) => { + if warnings & 2 != 0 && hi != 0 { + t.histogram_color(1) + } else if warnings & 1 != 0 && lo != 0 { + t.histogram_color(4) + } else { + Color32::TRANSPARENT + } + } + ClippingMode::None => Color32::TRANSPARENT, + } + }; + // Rebuilt for every new preview while a warning is on: one colour per proxy pixel, + // each independent, so it runs across cores (#211, P36). + #[cfg(not(target_arch = "wasm32"))] + let colors = { + use rayon::prelude::*; + d.pixels().par_iter().with_min_len(crate::camera_raw_ui::PARALLEL_MIN_PIXELS).map(color).collect() + }; + #[cfg(target_arch = "wasm32")] + let colors = d.pixels().iter().map(color).collect(); let image = egui::ColorImage::new([d.pw, d.ph], colors); match &mut d.scope.overlay_tex { Some(t) => t.set(image, egui::TextureOptions::NEAREST), diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 090c0a0b3c..1ff5fb4bd1 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -4,8 +4,10 @@ //! The preview runs [`photocraft_algo::camera_raw::develop`] on a CPU proxy of the layer //! (≤ 900 px, `pixel_scale` keeps pixel radii true to the full image), recomputed when a //! control changes: on a worker for the dialog's own controls, so a drag never waits for it, and -//! on the UI thread for the control channel, so a reply describes its own settings. OK runs `filter.cameraRaw` with the non-default settings, so the result is -//! one history step (or a smart filter on a smart object) and exactly replayable. +//! on the UI thread for the control channel, so a reply describes its own settings. Settings +//! whose stages are all per pixel develop on the GPU instead where it can (`camera_raw_gpu`). +//! OK runs `filter.cameraRaw` with the non-default settings, so the result is one history step +//! (or a smart filter on a smart object) and exactly replayable. //! //! Control channel: `ui.menu.invoke {"id":"filter.cameraRaw","params":{"ui":{"set":{…}, //! "commit":true | "cancel":true}}}`; the reply describes the dialog. `{"ui":{"saveSettings":true}}` @@ -26,6 +28,10 @@ use crate::theme::Tokens; use crate::widgets; const PROXY_SIDE: usize = 900; +/// Smallest run of proxy pixels one rayon task takes in the per-pixel passes: below it the +/// split costs more than the work. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const PARALLEL_MIN_PIXELS: usize = 16 * 1024; const PANEL_W: f32 = 330.0; const BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"]; @@ -43,7 +49,7 @@ pub struct CameraRawDialog { pub(crate) selection: Option>>, pub(crate) pw: usize, pub(crate) ph: usize, - full_w: usize, + pub(crate) full_w: usize, full_h: usize, preview_ppp: f32, pub(crate) detail: super::camera_raw_detail_ui::DetailPreview, @@ -74,6 +80,13 @@ pub struct CameraRawDialog { pending: Option>>, /// The last worker failure the status bar shows, with the preview revision it failed on. preview_error: Option<(String, u64)>, + /// The GPU develop, when the app may use one. + pub(crate) gpu: Option, + /// Names [`Self::proxy`] for the GPU pass's upload cache. + source_key: u64, + /// The last frame's develop alone, and where it ran ("gpu" or "cpu"). + develop_ms: f64, + pub(crate) develop_backend: &'static str, /// Settings before each finished edit, so Ctrl+Z works inside the dialog (#4100). undo: Vec, redo: Vec, @@ -85,7 +98,7 @@ impl CameraRawDialog { pub fn describe(&self, view: &crate::state::CameraRawScopeState, navigation: &crate::state::CameraRawPreviewState) -> Value { let h = self.histogram(); let mut result = json!({"layer": self.layer.0, "params": serde_json::to_value(&self.params).unwrap_or(Value::Null), "proxy": [self.pw, self.ph], - "renderMs": self.render_ms, "histogramMs": self.histogram_ms, "previewRevision": self.preview_revision, "before": self.show_before, + "renderMs": self.render_ms, "developMs": self.develop_ms, "developBackend": self.develop_backend, "histogramMs": self.histogram_ms, "previewRevision": self.preview_revision, "before": self.show_before, "histogram": {"source": if self.show_before { "before" } else { "after" }, "approximate": true, "size": [self.pw, self.ph], "sampleDomain": "preview-rgb", "range": [0, 1], "red": h.channels[0].as_slice(), "green": h.channels[1].as_slice(), "blue": h.channels[2].as_slice(), "samples": h.samples, "transparent": h.transparent, "invalid": h.invalid, "underflow": h.underflow, "overflow": h.overflow, "shadows": h.shadows, "highlights": h.highlights}}); @@ -93,7 +106,7 @@ impl CameraRawDialog { Target::SmartFilter(index) => Some(index), Target::NewFilter | Target::OpenRaw => None, }); - result["openingRaw"] = json!(self.raw_open.as_ref().map(|r| json!({"name": r.name, "path": r.path}))); + result["openingRaw"] = json!(self.raw_open.as_ref().map(|r| json!({"name": r.name, "path": r.path, "demosaic": r.demosaic.id()}))); result["view"] = json!(navigation); result["sourceSize"] = json!([self.full_w, self.full_h]); result["previewPending"] = json!(self.render_pending()); @@ -166,6 +179,8 @@ impl CameraRawDialog { size: (self.pw, self.ph), full_w: self.full_w, display: self.display.clone(), + gpu: self.gpu.clone(), + source_key: self.source_key, } } @@ -187,6 +202,8 @@ impl CameraRawDialog { self.processed = frame.px; self.scope.cache = None; self.render_ms = frame.ms; + self.develop_ms = frame.develop_ms; + self.develop_backend = frame.backend; self.preview_revision = self.preview_revision.saturating_add(1); } @@ -240,6 +257,20 @@ impl CameraRawDialog { pub(crate) fn render_pending(&self) -> bool { self.pending.is_some() } + + /// A newer frame means the worker failure is over: takes its message down, unless something + /// else has reported since. + fn clear_recovered_error(&mut self, ui: &mut crate::UiState) { + let Some((e, revision)) = &self.preview_error else { return }; + if *revision == self.preview_revision { + return; + } + if ui.status_error && ui.status == *e { + ui.status.clear(); + ui.status_error = false; + } + self.preview_error = None; + } } /// Everything one develop of the proxy reads, owned, so it can run off the UI thread. @@ -252,6 +283,8 @@ struct ProxyJob { size: (usize, usize), full_w: usize, display: std::sync::Arc, + gpu: Option, + source_key: u64, } /// A developed proxy, ready to show. @@ -262,33 +295,58 @@ struct ProxyFrame { image: egui::ColorImage, display: std::sync::Arc, ms: f64, + develop_ms: f64, + backend: &'static str, } impl ProxyJob { fn run(self) -> ProxyFrame { let t0 = crate::gpu_canvas::now_ms(); let (pw, ph) = self.size; - let mut px = self.proxy.to_vec(); let mut p = self.params; p.pixel_scale = (pw as f32 / self.full_w.max(1) as f32).min(1.0); - if let Some(curve) = &self.raw_curve { - let tone = raw_stage(&p); - photocraft_io::raw::retone(&mut px, pw, ph, curve, tone); + // The open-time dialog re-tones the proxy for each job; otherwise the GPU reads the + // proxy as it is and uploads it once. + let retoned = self.raw_curve.as_ref().map(|curve| { + let mut px = self.proxy.to_vec(); + photocraft_io::raw::retone(&mut px, pw, ph, curve, raw_stage(&p)); + px + }); + if retoned.is_some() { (p.shadows, p.highlights, p.clarity, p.texture, p.dehaze) = (0.0, 0.0, 0.0, 0.0, 0.0); } - photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float); + let develop_start = crate::gpu_canvas::now_ms(); + let key = retoned.is_none().then_some(self.source_key); + let input = retoned.as_deref().unwrap_or(&self.proxy); + let (mut px, backend) = match self.gpu.as_ref().and_then(|g| g.develop(input, key, pw, ph, &p, self.float)) { + Some(out) => (out, "gpu"), + None => { + let mut px = retoned.unwrap_or_else(|| self.proxy.to_vec()); + photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float); + (px, "cpu") + } + }; + let develop_ms = crate::gpu_canvas::now_ms() - develop_start; if let Some(mask) = &self.selection { - for ((out, original), coverage) in px.iter_mut().zip(self.proxy.iter()).zip(mask.iter()) { + let blend = |(out, (original, coverage)): (&mut [f32; 4], (&[f32; 4], &f32))| { for (out, original) in out.iter_mut().zip(original) { *out = *original + (*out - *original) * coverage; } + }; + // Each pixel is independent, so bands of the proxy blend in parallel (#211, P36). + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + px.par_iter_mut().zip(self.proxy.par_iter().zip(mask.par_iter())).with_min_len(PARALLEL_MIN_PIXELS).for_each(blend); } + #[cfg(target_arch = "wasm32")] + px.iter_mut().zip(self.proxy.iter().zip(mask.iter())).for_each(blend); } let histogram_start = crate::gpu_canvas::now_ms(); let histogram = RgbHistogram::from_rgba(&px); let histogram_ms = crate::gpu_canvas::now_ms() - histogram_start; let image = CameraRawDialog::image(&px, pw, ph, &self.display); - ProxyFrame { px, histogram, histogram_ms, image, display: self.display, ms: crate::gpu_canvas::now_ms() - t0 } + ProxyFrame { px, histogram, histogram_ms, image, display: self.display, ms: crate::gpu_canvas::now_ms() - t0, develop_ms, backend } } } @@ -305,10 +363,10 @@ enum Target { /// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens /// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open -/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or -/// Dehaze changed (those act on the sensor data, see [`photocraft_io::raw::import_raw_tuned`]) -/// and applies the other settings as one Camera Raw step, then marks the document unmodified. -/// Cancel closes it. +/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture, +/// Dehaze or the demosaic method changed (those act on the sensor data, see +/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw +/// step, then marks the document unmodified. Cancel closes it. #[derive(Clone, Debug)] pub struct RawOpen { pub document: photocraft_doc::DocId, @@ -316,6 +374,10 @@ pub struct RawOpen { pub path: Option, /// The file, when it was opened from bytes (no path to read it again from). pub bytes: Option>>, + /// The demosaic method the open developed it with. + pub developed_with: photocraft_io::raw::Demosaic, + /// The method chosen in the dialog: Open re-develops when it differs from `developed_with`. + pub demosaic: photocraft_io::raw::Demosaic, } /// Should an interactive open of `name` show the open-time Camera Raw dialog? `warnings` are @@ -330,16 +392,32 @@ pub fn without_develop_note(warnings: &[String]) -> Vec { warnings.iter().filter(|w| !w.contains(photocraft_io::raw::DEVELOPED_NOTE)).cloned().collect() } -/// Queues the open-time dialog for the active document (shown on the next frame). -pub fn queue_open_dialog(app: &mut PhotocraftApp, name: &str, path: Option<&str>, bytes: Option<&[u8]>) { - if let Some(st) = app.session.active() { - app.pending_raw_open = Some(RawOpen { - document: st.doc.id, - name: name.to_string(), - path: path.map(str::to_string), - bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), - }); +/// Queues the open-time dialog for the document whose import completed, independent of the +/// active tab. A delayed completion for a closed document must not review another photograph. +/// `warnings` are the import's: its note says which demosaic method the open developed with. +pub fn queue_open_dialog( + app: &mut PhotocraftApp, + document: photocraft_doc::DocId, + name: &str, + path: Option<&str>, + bytes: Option<&[u8]>, + warnings: &[String], +) -> Result<(), String> { + if document_index(app, document).is_none() { + return Err("the imported raw document is missing".into()); } + // The method the open actually used (`jobs::import_options` at the job's start), from its + // note: the preference may have changed while a background open ran. + let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); + app.pending_raw_open = Some(RawOpen { + document, + name: name.to_string(), + path: path.map(str::to_string), + bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), + developed_with: method, + demosaic: method, + }); + Ok(()) } /// Opens the queued open-time dialog on its document's (only) layer. @@ -347,7 +425,32 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul let index = document_index(app, raw.document).ok_or("the raw document was closed")?; app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; - open_pixels(app, ctx, layer, surf, CameraRaw::default(), Coverage::None, Target::OpenRaw)?; + // The file's sidecar (`IMG_0001.xmp` beside it) fills the dialog, as Camera Raw's does; the + // status bar names what it could not apply. + let mut params = CameraRaw::default(); + let mut from_sidecar = false; + if let Some((loaded, report, file)) = raw.path.as_deref().and_then(read_sidecar) { + params = loaded; + from_sidecar = true; + app.ui.status = if report.ignored.is_empty() { + crate::i18n::fmt(tl!("Settings from {name}"), &[("name", &file)]) + } else { + crate::i18n::fmt( + tl!("Settings from {name}; {count} not supported: {names}"), + &[("name", &file), ("count", &report.ignored.len().to_string()), ("names", &report.ignored.join(", "))], + ) + }; + app.ui.status_error = false; + } + open_pixels(app, ctx, layer, surf, params, Coverage::None, Target::OpenRaw)?; + // Camera Raw Defaults › Apply auto tone: a file without its own settings opens with Auto. + if !from_sidecar + && app.session.prefs().raw_defaults.apply_auto_tone + && let Some(d) = app.camera_raw.as_mut() + { + photocraft_algo::camera_raw::auto_tone(&d.proxy, &mut d.params); + d.dirty = true; + } let curve = match (&raw.bytes, &raw.path) { (Some(b), _) => Some(photocraft_io::raw::tone_curve(b)), #[cfg(not(target_arch = "wasm32"))] @@ -356,7 +459,10 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul }; if let Some(d) = app.camera_raw.as_mut() { d.layer_name = raw.name.clone(); - d.raw_curve = curve.map(std::sync::Arc::new); + // The full-size preview retones the whole frame with it, as Open will. + let curve = curve.map(std::sync::Arc::new); + d.detail.raw_curve = curve.clone(); + d.raw_curve = curve; d.raw_open = Some(raw); } Ok(()) @@ -391,8 +497,10 @@ pub struct Redevelop { params: CameraRaw, raw: RawOpen, /// The job is the final Camera Raw step (`filter.cameraRaw`), not the re-develop: when it - /// lands the document is marked unmodified. + /// lands the Camera Raw Defaults' conversions follow and the document is marked unmodified. filter_step: bool, + /// The Camera Raw Defaults the open started with, for the conversions after the filter step. + defaults: photocraft_engine::prefs::RawDefaults, } /// The sliders a raw open applies while developing, before the tone curve (as Camera Raw does). @@ -406,18 +514,22 @@ fn raw_stage(p: &CameraRaw) -> photocraft_io::raw::Tone { /// on) it runs as a job like the open itself: the reply is `{"job", "pending": true}`, the /// document is locked meanwhile, and [`on_redevelop_event`] finishes when it lands. Elsewhere /// (tests, the web) it runs inline. -fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) -> Result { +fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, loaded: Option<&CameraRaw>) -> Result { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; + write_sidecar(app, raw, params, loaded); let tuning = photocraft_io::raw::RawTuning { temperature: params.temperature, tint: params.tint, exposure: params.exposure, + demosaic: raw.demosaic, shadows: params.shadows, highlights: params.highlights, clarity: params.clarity, texture: params.texture, dehaze: params.dehaze, }; + // The open itself: the default settings, developed with the method it was opened with. + let as_opened = photocraft_io::raw::RawTuning { demosaic: raw.developed_with, ..Default::default() }; // Without the file the raw-stage settings stay RGB adjustments, like the filter's. let source = match (&raw.bytes, &raw.path) { (Some(b), _) => Some(photocraft_engine::jobs::OpenSource::Bytes(b.clone())), @@ -425,8 +537,9 @@ fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) - (None, Some(p)) => Some(photocraft_engine::jobs::OpenSource::Path(p.clone())), _ => None, }; - let Some(source) = source.filter(|_| tuning != photocraft_io::raw::RawTuning::default()) else { - return finish_open_raw(app, index, params.clone(), None, Some(raw)); + let Some(source) = source.filter(|_| tuning != as_opened) else { + let defaults = app.session.prefs().raw_defaults.clone(); + return finish_open_raw(app, index, params.clone(), None, raw, &defaults); }; app.session.set_active(index); let (name, doc_id) = (raw.name.clone(), raw.document); @@ -454,9 +567,13 @@ fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) - photocraft_engine::jobs::Started::Done(v) }; match started { - photocraft_engine::jobs::Started::Done(v) => finish_redevelop(app, params, raw, &v), + photocraft_engine::jobs::Started::Done(v) => { + let defaults = app.session.prefs().raw_defaults.clone(); + finish_redevelop(app, params, raw, &v, &defaults) + } photocraft_engine::jobs::Started::Job(job) => { - app.raw_redevelop = Some(Redevelop { job, params: params.clone(), raw: raw.clone(), filter_step: false }); + let defaults = app.session.prefs().raw_defaults.clone(); + app.raw_redevelop.push(Redevelop { job, params: params.clone(), raw: raw.clone(), filter_step: false, defaults }); app.ui.status = label; app.ui.status_error = false; Ok(json!({"job": job.0, "pending": true})) @@ -465,34 +582,47 @@ fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) - } /// Swap the default-developed document `doc_id` for the re-developed one, at the same tab -/// position and with the same path. Returns `{document, previous, warnings, wb}`. +/// position and with the same path. Returns `{document, docId, previous, warnings, wb}`: +/// `document` is the tab index at apply time, `docId` is the replacement's stable identity. fn replace_raw_document( s: &mut photocraft_engine::Session, doc_id: photocraft_doc::DocId, - r: photocraft_io::ImportResult, + mut r: photocraft_io::ImportResult, wb: bool, ) -> photocraft_engine::Result { let index = s.documents().iter().position(|st| st.doc.id == doc_id).ok_or_else(|| photocraft_engine::EngineError::Other("the raw document was closed".into()))?; let path = s.documents().get(index).and_then(|st| st.path.clone()); + // The resolution it opened with (Preferences › Raw Defaults), which re-developing keeps. + if let Some(st) = s.documents().get(index) { + r.document.resolution_dpi = st.doc.resolution_dpi; + } s.close(index); let (opened, _) = s.open_document(r.document, path); + let replacement = s.documents().get(opened).ok_or(photocraft_engine::EngineError::NoDocument)?.doc.id; let index = match s.execute("document.move", json!({"document": opened, "to": index})) { Ok(moved) => moved.get("document").and_then(Value::as_u64).map_or(opened, |i| i as usize), Err(_) => opened, }; s.set_active(index); - Ok(json!({"document": index, "previous": doc_id.0, "warnings": r.warnings, "wb": wb})) + Ok(json!({"document": index, "docId": replacement.0, "previous": doc_id.0, "warnings": r.warnings, "wb": wb})) } /// After the re-develop: keep the old document's view (zoom, scroll) on the new one, report /// the import's notes, then apply the remaining settings. -fn finish_redevelop(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, v: &Value) -> Result { - let index = v.get("document").and_then(Value::as_u64).ok_or("the re-develop returned no document")? as usize; - if let Some(new_id) = app.session.documents().get(index).map(|st| st.doc.id) { - for id in app.view_docs.iter_mut().filter(|id| **id == raw.document) { - *id = new_id; - } +fn finish_redevelop( + app: &mut PhotocraftApp, + params: &CameraRaw, + raw: &RawOpen, + v: &Value, + defaults: &photocraft_engine::prefs::RawDefaults, +) -> Result { + // A preceding completion (for example Save and Close) may have shifted the tab indices. + // Validate identity before remapping views, filtering or marking any document unmodified. + let replacement = v.get("docId").and_then(Value::as_u64).map(photocraft_doc::DocId).ok_or("the re-develop returned no document identity")?; + let index = document_index(app, replacement).ok_or("the re-developed document was closed")?; + for id in app.view_docs.iter_mut().filter(|id| **id == raw.document) { + *id = replacement; } 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(); @@ -508,32 +638,254 @@ fn finish_redevelop(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, rest.temperature = 0.0; rest.tint = 0.0; } - finish_open_raw(app, index, rest, Some(true), Some(raw)) + finish_open_raw(app, index, rest, Some(true), raw, defaults) } -/// The other settings as one Camera Raw step on document `index`, then the document is marked -/// unmodified (developing is part of opening). The step runs like any filter (in the -/// background in the desktop app); the document is marked unmodified when it lands. -fn finish_open_raw(app: &mut PhotocraftApp, index: usize, rest: CameraRaw, redeveloped: Option, raw: Option<&RawOpen>) -> Result { +/// The other settings as one Camera Raw step on document `index`, then the Camera Raw Defaults' +/// conversions, then the document is marked unmodified (developing is part of opening). The +/// step runs like any filter (in the background in the desktop app); the conversions follow and +/// the document is marked unmodified when it lands. The step works on the developed ProPhoto +/// pixels the dialog previewed, and the conversion to the workflow space comes last, as in +/// Camera Raw. +fn finish_open_raw( + app: &mut PhotocraftApp, + index: usize, + mut rest: CameraRaw, + redeveloped: Option, + raw: &RawOpen, + d: &photocraft_engine::prefs::RawDefaults, +) -> Result { app.session.set_active(index); app.sync_views(); let mut result = json!({"document": index, "redeveloped": redeveloped.unwrap_or(false)}); + let mut applied = output_sharpening(d, &mut rest); + applied.extend(raw_conversions(d)); + if !applied.is_empty() { + result["defaults"] = json!(applied); + } if !rest.is_identity() { let layer = app.session.active().and_then(|st| st.active_layer).ok_or("no layer to develop")?; let mut p = non_defaults(&rest); p["layer"] = json!(layer.0); let r = app.run("filter.cameraRaw", p)?; if r.get("pending").and_then(Value::as_bool) == Some(true) - && let (Some(job), Some(raw)) = (r.get("job").and_then(Value::as_u64), raw) + && let Some(job) = r.get("job").and_then(Value::as_u64) { - app.raw_redevelop = Some(Redevelop { job: photocraft_engine::jobs::JobId(job), params: rest.clone(), raw: raw.clone(), filter_step: true }); + let job = photocraft_engine::jobs::JobId(job); + app.raw_redevelop.push(Redevelop { job, params: rest.clone(), raw: raw.clone(), filter_step: true, defaults: d.clone() }); + result["filter"] = r; + mark_unmodified(app, index); + return Ok(result); } result["filter"] = r; } + // The Camera Raw step has landed: a failed conversion is reported, and the open still + // completes (a second Open would apply the step twice). + if let Err(e) = apply_raw_conversions(app, index, d) { + crate::notices::error(app, format!("{}: {e}", raw.name)); + } mark_unmodified(app, index); Ok(result) } +/// The sidecar beside a raw file: `IMG_0001.xmp` for `IMG_0001.CR2`, as Camera Raw names it. +pub fn sidecar_path(path: &str) -> String { + match path.rsplit_once('.') { + Some((stem, ext)) if !ext.contains(['/', '\\']) => format!("{stem}.xmp"), + _ => format!("{path}.xmp"), + } +} + +/// A DNG carries its settings inside the file in Camera Raw; a sidecar is read for one too, +/// but never written beside it. +fn is_dng(path: &str) -> bool { + path.rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("dng")) +} + +/// The settings of the sidecar beside `path`, when there is one that parses: the settings, what +/// the loader skipped, and the sidecar's file name. A sidecar that doesn't parse is ignored (the +/// raw still opens) and reported in the log. +#[cfg(not(target_arch = "wasm32"))] +fn read_sidecar(path: &str) -> Option<(CameraRaw, photocraft_io::camera_raw_xmp::LoadReport, String)> { + let sidecar = sidecar_path(path); + let Some(bytes) = read_bounded(&sidecar) else { + log::warn!("{sidecar}: not a readable sidecar of at most {} bytes, ignored", photocraft_io::camera_raw_xmp::MAX_XMP_BYTES); + return None; + }; + let mut params = CameraRaw::default(); + match photocraft_io::camera_raw_xmp::load(&mut params, &bytes) { + Ok(report) => Some((params, report, crate::file_open::display_name(&sidecar))), + Err(e) => { + log::warn!("{sidecar}: sidecar ignored: {e}"); + None + } + } +} +#[cfg(target_arch = "wasm32")] +fn read_sidecar(_: &str) -> Option<(CameraRaw, photocraft_io::camera_raw_xmp::LoadReport, String)> { + None +} + +/// Keep the develop settings beside the raw file (Camera Raw Defaults › Write xmp sidecars): +/// written when the settings aren't the defaults or PhotoCraft's own sidecar is there, so a +/// file reopens as it was developed. `loaded` are the settings the open started from: nothing +/// is written when they didn't change. Not for DNGs, never over a sidecar another application +/// wrote (Lightroom's and Bridge's hold crops, masks, ratings and keywords PhotoCraft can't keep), +/// never for a control or MCP request (it has no authority to write beside the user's files), and +/// never fatal: a folder that can't be written to is reported in a notice. +fn write_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &CameraRaw, loaded: Option<&CameraRaw>) { + if !app.session.prefs().raw_defaults.write_xmp_sidecars || loaded == Some(params) { + return; + } + if app.automation_input || app.session.authorize.is_some() { + log::info!("{}: sidecar not written for an automation request", raw.name); + return; + } + let Some(path) = raw.path.as_deref().filter(|p| !is_dng(p)) else { return }; + let sidecar = sidecar_path(path); + let name = crate::file_open::display_name(&sidecar); + match existing_sidecar_is_ours(&sidecar) { + Some(false) => { + let kept = crate::i18n::fmt(tl!("Kept {name}: written by another application"), &[("name", &name)]); + crate::notices::post(app, kept, Vec::new(), false, None); + return; + } + None if params.is_identity() => return, + _ => {} + } + let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes(); + let Some(write) = app.services.write.as_mut() else { return }; + if let Err(e) = write(&sidecar, &bytes) { + crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)])); + } +} + +/// `None` when there is no sidecar at `sidecar`, else whether PhotoCraft wrote it. One that +/// can't be read or is too large to be ours counts as another application's. +#[cfg(not(target_arch = "wasm32"))] +fn existing_sidecar_is_ours(sidecar: &str) -> Option { + std::fs::symlink_metadata(sidecar).ok()?; + Some(read_bounded(sidecar).is_some_and(|b| photocraft_io::camera_raw_xmp::is_photocraft_xmp(&b))) +} + +/// The bytes of the regular file at `path` when it holds at most +/// [`photocraft_io::camera_raw_xmp::MAX_XMP_BYTES`]; `None` for anything else (missing, a folder, +/// a pipe or device that might never end, larger). Never reads more than the cap plus one byte. +#[cfg(not(target_arch = "wasm32"))] +fn read_bounded(path: &str) -> Option> { + use std::io::Read; + let cap = photocraft_io::camera_raw_xmp::MAX_XMP_BYTES; + // Checked before opening: opening a pipe for reading waits for a writer. + if !std::fs::metadata(path).ok()?.is_file() { + return None; + } + let file = std::fs::File::open(path).ok()?; + let mut bytes = Vec::new(); + file.take(cap as u64 + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() <= cap).then_some(bytes) +} +#[cfg(target_arch = "wasm32")] +fn existing_sidecar_is_ours(_: &str) -> Option { + None +} + +/// Camera Raw Defaults › Sharpen For: output sharpening, folded into the Camera Raw step `rest` +/// as the workflow's Sharpen For is. Returns what it applied. +fn output_sharpening(d: &photocraft_engine::prefs::RawDefaults, rest: &mut CameraRaw) -> Vec<&'static str> { + match d.sharpen_for.preset() { + Some((amount, radius)) if rest.sharpen_amount == 0.0 => { + rest.sharpen_amount = amount; + rest.sharpen_radius = radius; + vec!["sharpenFor"] + } + _ => Vec::new(), + } +} + +/// The Camera Raw Defaults [`apply_raw_conversions`] applies. Resolution is not among them: the +/// open itself sets it (`file_cmds::raw_defaults`), and a re-develop keeps it. +fn raw_conversions(d: &photocraft_engine::prefs::RawDefaults) -> Vec<&'static str> { + use photocraft_engine::prefs::{RawColorSpace, RawDepth}; + let mut out = Vec::new(); + // Raws develop into ProPhoto-compatible RGB (`photocraft_io::raw`); the other spaces are a + // conversion. + if d.color_space != RawColorSpace::ProPhoto { + out.push("colorSpace"); + } + if d.bit_depth == RawDepth::Eight { + out.push("bitDepth"); + } + if d.open_as_smart_object { + out.push("openAsSmartObject"); + } + out +} + +/// Camera Raw Defaults (Preferences) after the Camera Raw step: the colour space and bit depth +/// the developed document opens in, and Open as Smart Object. +fn apply_raw_conversions(app: &mut PhotocraftApp, index: usize, d: &photocraft_engine::prefs::RawDefaults) -> Result<(), String> { + app.session.set_active(index); + for step in raw_conversions(d) { + let (id, params) = match step { + "colorSpace" => ("edit.convertToProfile", json!({"profile": d.color_space.profile_id(), "intent": "relative"})), + "bitDepth" => ("image.mode.bits8", json!({})), + _ => ("layer.smartObjects.convertToSmartObject", json!({})), + }; + app.session.execute(id, params).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// A raw that opened without the dialog (Camera Raw Defaults › Open in Camera Raw off): its +/// sidecar's settings develop it as the dialog's Open would, and the Camera Raw Defaults apply. +/// `warnings` are the import's; only a developed raw qualifies. +pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, path: Option<&str>, warnings: &[String]) { + if !warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)) { + return; + } + let Some(document) = app.session.documents().get(index).map(|st| st.doc.id) else { return }; + let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); + let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method }; + let mut params = CameraRaw::default(); + let sidecar = path.and_then(read_sidecar).map(|(loaded, _, _)| loaded); + if let Some(loaded) = &sidecar { + params = loaded.clone(); + } else if app.session.prefs().raw_defaults.apply_auto_tone + && let Some(px) = app.session.active().and_then(|st| st.doc.layer(st.active_layer?)).and_then(|l| l.surface()).map(|s| sample_pixels(s, 512)) + { + photocraft_algo::camera_raw::auto_tone(&px, &mut params); + } + let r = if params.is_identity() { + let defaults = app.session.prefs().raw_defaults.clone(); + finish_open_raw(app, index, params, None, &raw, &defaults) + } else { + commit_open_raw(app, ¶ms, &raw, sidecar.as_ref()) + }; + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// Every k-th pixel of a surface's content, k chosen so the longer side has about `max_side` +/// samples: enough for a histogram (Auto tone) without reading a whole raw. +fn sample_pixels(surf: &photocraft_raster::Surface, max_side: usize) -> Vec<[f32; 4]> { + let area = surf.content_bounds(); + let (w, h) = (area.x1.saturating_sub(area.x0).max(0) as usize, area.y1.saturating_sub(area.y0).max(0) as usize); + if w == 0 || h == 0 { + return Vec::new(); + } + let k = w.max(h).div_ceil(max_side.max(1)).max(1); + let mut out = Vec::with_capacity(w.div_ceil(k) * h.div_ceil(k)); + let mut row = vec![[0.0f32; 4]; w]; + for y in (0..h).step_by(k) { + let yy = area.y0.saturating_add(y as i32); + surf.read_rgba_into(Rect::new(area.x0, yy, area.x1, yy.saturating_add(1)), &mut row); + out.extend(row.iter().step_by(k).copied()); + } + out +} + fn mark_unmodified(app: &mut PhotocraftApp, index: usize) { let active = app.session.active_index(); app.session.set_active(index); @@ -549,18 +901,29 @@ fn mark_unmodified(app: &mut PhotocraftApp, index: usize) { /// A finished background re-develop ([`REDEVELOP_JOB`]) or open-time Camera Raw step. `true` /// when the event was one of those (handled here). pub fn on_redevelop_event(app: &mut PhotocraftApp, e: &photocraft_engine::jobs::JobEvent) -> bool { - let Some(pending) = app.raw_redevelop.take_if(|r| r.job == e.id) else { return false }; + let Some(at) = app.raw_redevelop.iter().position(|r| r.job == e.id) else { return false }; + let pending = app.raw_redevelop.remove(at); if pending.filter_step { - if matches!(e.outcome, photocraft_engine::jobs::JobOutcome::Done(_)) - && let Some(index) = e.document.and_then(|id| document_index(app, id)) - { - mark_unmodified(app, index); + // The Camera Raw Defaults' conversions follow the Camera Raw step, whatever its outcome: + // the document still opens in the workflow's colour space and depth. + if let Some(index) = e.document.and_then(|id| document_index(app, id)) { + let active = app.session.active_index(); + if let Err(err) = apply_raw_conversions(app, index, &pending.defaults) { + crate::notices::error(app, format!("{}: {err}", pending.raw.name)); + } + if let Some(i) = active { + app.session.set_active(i); + } + if matches!(e.outcome, photocraft_engine::jobs::JobOutcome::Done(_)) { + mark_unmodified(app, index); + } + app.sync_views(); } // The usual filter reporting still applies. return false; } match &e.outcome { - photocraft_engine::jobs::JobOutcome::Done(v) => match finish_redevelop(app, &pending.params, &pending.raw, v) { + photocraft_engine::jobs::JobOutcome::Done(v) => match finish_redevelop(app, &pending.params, &pending.raw, v, &pending.defaults) { Ok(_) => { app.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", &pending.raw.name)]); app.ui.status_error = false; @@ -708,6 +1071,8 @@ fn open_pixels( app.ui.camera_raw_scope.selected_region = false; } let float = surf.format().sample == photocraft_color::SampleType::F32; + // Builds the GPU pass the first time any Camera Raw dialog opens. + let gpu = crate::camera_raw_gpu::for_app(app); let mut d = CameraRawDialog { layer, layer_name: name, @@ -756,6 +1121,10 @@ fn open_pixels( raw_curve: None, pending: None, preview_error: None, + gpu, + source_key: crate::camera_raw_gpu::source_key(), + develop_ms: 0.0, + develop_backend: "cpu", undo: Vec::new(), redo: Vec::new(), edit_origin: None, @@ -796,9 +1165,9 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val }; for (key, value) in fields { match key.as_str() { - "set" | "scope" | "view" => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" if value.is_boolean() => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" => { + "set" | "scope" | "view" | "demosaic" => {} + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" if value.is_boolean() => {} + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "undo" | "redo" | "auto" => { return Some(Err(format!("Camera Raw {key} must be a boolean"))); } _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), @@ -835,6 +1204,10 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti // occurs until every part of the request has passed validation. let d = app.camera_raw.as_ref()?; let mut next_params = d.params.clone(); + // Auto (the Light panel's button): the tone sliders from the image's histogram. + if ui.get("auto").and_then(Value::as_bool) == Some(true) { + photocraft_algo::camera_raw::auto_tone(&d.proxy, &mut next_params); + } if let Some(set) = ui.get("set") { if !set.is_object() { return Some(Err("bad Camera Raw settings: expected an object".into())); @@ -870,7 +1243,18 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti }, None => None, }; + let demosaic = match ui.get("demosaic") { + None => None, + Some(_) if d.raw_open.is_none() => return Some(Err("demosaic applies only while a raw file opens".into())), + Some(v) => match v.as_str().and_then(photocraft_io::raw::Demosaic::from_id) { + Some(m) => Some(m), + None => return Some(Err("demosaic must be ahd, mhc or bilinear".into())), + }, + }; let d = app.camera_raw.as_mut()?; + if let (Some(m), Some(raw)) = (demosaic, d.raw_open.as_mut()) { + raw.demosaic = m; + } if let Some(view) = navigation { app.ui.camera_raw_preview = view; } @@ -897,6 +1281,8 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti if d.dirty { d.render(ctx); } + // The control channel can read the status before the next frame draws. + d.clear_recovered_error(&mut app.ui); if app.ui.camera_raw_scope.vectorscope && let Some(d) = app.camera_raw.as_mut() { @@ -1025,14 +1411,33 @@ fn commit(app: &mut PhotocraftApp) -> Result { } Target::OpenRaw => { let raw = d.raw_open.clone().ok_or("no raw file is being opened")?; - let params = d.params.clone(); - commit_open_raw(app, ¶ms, &raw)? + let (params, original) = (d.params.clone(), d.original.clone()); + commit_open_raw(app, ¶ms, &raw, Some(&original))? } }; app.camera_raw = None; Ok(result) } +/// The open-time demosaic choice (at the top of Detail). Photoshop's Camera Raw picks one method +/// for every file; here it is the user's. Open re-develops with it; the preview keeps the method +/// the file was opened with (the differences are finer than the preview's pixels). +fn demosaic_row(ui: &mut egui::Ui, m: &mut photocraft_io::raw::Demosaic) { + use photocraft_io::raw::Demosaic; + ui.horizontal(|ui| { + ui.label(crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", "Demosaic")).on_hover_text(tl!( + "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." + )); + let options = [ + (Demosaic::Ahd, "AHD (adaptive homogeneity)", "Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest."), + (Demosaic::Mhc, "Malvar–He–Cutler", "Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail."), + (Demosaic::Bilinear, "Bilinear", "The fastest and the softest, with colour fringing on fine detail."), + ]; + widgets::dropdown_with_tooltips(ui, "camera-raw-demosaic", tl!("Demosaic"), m, &options, 180.0); + }); + ui.add_space(4.0); +} + fn temp_stops() -> Vec { vec![Color32::from_rgb(70, 120, 230), Color32::from_rgb(200, 200, 200), Color32::from_rgb(235, 200, 60)] } @@ -1135,7 +1540,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Camera Raw is modal, as in Photoshop: the main window is dimmed and takes no input while it // is open (in a separate window, or drawn over it where there are no extra windows). let main = ctx.content_rect(); - egui::Area::new(egui::Id::new("camera-raw-modal")).order(egui::Order::Foreground).fixed_pos(main.min).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("camera-raw-modal")).order(egui::Order::Foreground).fixed_pos(main.min).show(ctx, |ui| { let (r, _) = ui.allocate_exact_size(main.size(), Sense::click_and_drag()); ui.painter().rect_filled(r, 0.0, Tokens::get(ui.ctx()).scrim); }); @@ -1171,7 +1576,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let screen = ctx.content_rect(); let current_display = app.session.active().and_then(|st| app.session.color.canvas_display(&st.doc).ok()); let mut action: Option<&str> = None; - egui::Area::new(egui::Id::new("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.camera_raw.as_mut() else { return }; history_keys(d, ctx); let pointer = ctx.input(|i| i.pointer.primary_down()); @@ -1192,17 +1597,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { app.ui.status_error = true; } d.request_render(ctx); - // A newer frame means the failure is over: take its message down, unless something else - // has reported since. - if let Some((e, revision)) = &d.preview_error - && *revision != d.preview_revision - { - if app.ui.status_error && app.ui.status == *e { - app.ui.status.clear(); - app.ui.status_error = false; - } - d.preview_error = None; - } + d.clear_recovered_error(&mut app.ui); let (full, _) = ui.allocate_exact_size(screen.size(), Sense::click()); let painter = ui.painter().clone(); painter.rect_filled(full, 0.0, t.chrome); @@ -1215,6 +1610,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let ok_label = if d.target == Target::OpenRaw { tl!("Open") } else { tl!("OK") }; let footer_h = 48.0; let body = ERect::from_min_max(pos2(full.left(), title.bottom()), pos2(full.right(), full.bottom() - footer_h)); + let animated_zoom = app.session.prefs().general.animated_zoom; let scope = &mut app.ui.camera_raw_scope; // Clip both pixels and scope overlays before painting the settings dock/footer. let preview_body = ERect::from_min_max(body.min, pos2((body.right() - PANEL_W).max(body.left() + 1.0), body.bottom())); @@ -1229,7 +1625,8 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { let mut preview = ui.new_child(egui::UiBuilder::new().max_rect(view)); preview.set_clip_rect(view.intersect(ui.clip_rect())); preview.painter().rect_filled(view, 0.0, t.canvas); - let (r, response, panning, zoom_box) = super::camera_raw_preview_ui::interact(&mut preview, navigation, view, size, &mut scope.sampler_tool); + let (r, response, panning, zoom_box) = + super::camera_raw_preview_ui::interact(&mut preview, navigation, view, size, &mut scope.sampler_tool, animated_zoom); let tex = if d.show_before { d.before_tex.as_ref() } else { d.tex.as_ref() }; if let Some(tex) = tex { // Never tile a checker across the offscreen extent of a highly zoomed image. @@ -1270,9 +1667,16 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { egui::ScrollArea::vertical().id_salt("camera-raw-props").show(&mut props, |ui| { ui.spacing_mut().item_spacing.y = 4.0; let p = &mut d.params; + let proxy = &d.proxy; let alt = &mut d.scope.alt_tone; let (ts, tn) = (temp_stops(), tint_stops()); section(ui, "Light", true, |ui| { + // Auto: Camera Raw's one-click tone from the histogram; the sliders show what it + // chose, so each can be adjusted afterwards. + if ui.button(tl!("Auto")).on_hover_text(tl!("Set the tone sliders from the image")).clicked() { + photocraft_algo::camera_raw::auto_tone(proxy, p); + dirty = true; + } tone_row(ui, &mut dirty, alt, ToneZone::Exposure, &mut p.exposure); row(ui, &mut dirty, "Contrast", &mut p.contrast, -100.0..=100.0, None); tone_row(ui, &mut dirty, alt, ToneZone::Highlights, &mut p.highlights); @@ -1296,6 +1700,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { if widgets::dropdown( ui, "cr-vig-style", + tl!("Vignetting"), &mut style, &[ ("highlightPriority".to_string(), tl!("Highlight Priority")), @@ -1329,7 +1734,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { for (i, source) in ["Hue", "Saturation", "Luminance"].iter().enumerate() { let name = crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", source); let response = widgets::pill_tab(ui, name, d.mixer_tab == i); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), d.mixer_tab == i, name)); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), d.mixer_tab == i, name)); if response.clicked() { d.mixer_tab = i; } @@ -1342,7 +1747,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { }; for (k, name) in BANDS.iter().enumerate() { let c = photocraft_algo::camera_raw::HSL_BANDS[k]; - let base = hue_color(c); + let base = widgets::hue_color(c); let grad = [Color32::from_gray(128), base]; row(ui, &mut dirty, name, &mut arr[k], -100.0..=100.0, Some(&grad)); } @@ -1356,6 +1761,9 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { row(ui, &mut dirty, "Balance", &mut p.grade_balance, -100.0..=100.0, None); }); section(ui, "Detail", false, |ui| { + if let Some(raw) = d.raw_open.as_mut() { + demosaic_row(ui, &mut raw.demosaic); + } widgets::section_label(ui, tl!("Sharpening")); row(ui, &mut dirty, "Amount", &mut p.sharpen_amount, 0.0..=150.0, None); row(ui, &mut dirty, "Radius", &mut p.sharpen_radius, 0.5..=3.0, None); @@ -1367,6 +1775,19 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { row(ui, &mut dirty, "Color", &mut p.noise_color, 0.0..=100.0, None); row(ui, &mut dirty, "Color Detail", &mut p.noise_color_detail, 0.0..=100.0, None); }); + // Camera Raw's Calibration panel: a tint for the shadows and each primary's hue and + // saturation (Process Version is implied: 2012 and later). + section(ui, "Calibration", false, |ui| { + widgets::section_label(ui, tl!("Shadows")); + row(ui, &mut dirty, "Tint", &mut p.shadow_tint, -100.0..=100.0, Some(&tn)); + for (i, name) in ["Red Primary", "Green Primary", "Blue Primary"].iter().enumerate() { + widgets::section_label(ui, crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", name)); + ui.push_id(name, |ui| { + row(ui, &mut dirty, "Hue", &mut p.calibration_hue[i], -100.0..=100.0, None); + row(ui, &mut dirty, "Saturation", &mut p.calibration_sat[i], -100.0..=100.0, None); + }); + } + }); }); super::camera_raw_scope_ui::floating(ctx, d, scope, right); super::camera_raw_scope_ui::shortcuts(ctx, scope); @@ -1439,20 +1860,6 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } } -fn hue_color(h: f32) -> Color32 { - let h6 = (h.rem_euclid(360.0)) / 60.0; - let x = 1.0 - ((h6 % 2.0) - 1.0).abs(); - let (r, g, b) = match h6 as u32 { - 0 => (1.0, x, 0.0), - 1 => (x, 1.0, 0.0), - 2 => (0.0, 1.0, x), - 3 => (0.0, x, 1.0), - 4 => (x, 0.0, 1.0), - _ => (1.0, 0.0, x), - }; - Color32::from_rgb((r * 220.0) as u8, (g * 220.0) as u8, (b * 220.0) as u8) -} - #[cfg(test)] mod tests { use super::*; @@ -1669,6 +2076,14 @@ mod tests { finish_worker(app.camera_raw.as_mut().unwrap(), &ctx); frame(&mut app); assert!(app.ui.status_error && app.ui.status == "something else failed", "{}", app.ui.status); + + // A control-channel edit renders on its own thread; the status is clear before any frame. + fail(&mut app); + frame(&mut app); + assert!(app.ui.status_error); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": -0.5}}})).unwrap().unwrap(); + assert!(!app.ui.status_error && app.ui.status.is_empty(), "{}", app.ui.status); + assert_eq!(crate::control::inspect(&app, &ctx)["statusError"], false); } #[test] @@ -1763,15 +2178,19 @@ mod tests { fn raw_app() -> (PhotocraftApp, Vec) { use photocraft_raw::testgen::{DngSpec, mosaic, scene}; let services = crate::Services { - import: Some(Box::new(|name: &str, b: &[u8], depth: usize| { - photocraft_io::import_with_svg_group_depth(name, b, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name: &str, b: &[u8], opts: &photocraft_io::ImportOptions| { + photocraft_io::import_with_options(name, b, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }; let (w, h) = (48, 32); let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 30000)); spec.as_shot_neutral = Some([0.5, 1.0, 0.7]); - (PhotocraftApp::new(photocraft_engine::Session::new(), services), spec.build()) + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + // The pixel comparisons below read the developed ProPhoto values: no conversion on open + // (Camera Raw Defaults › Color space is Adobe RGB otherwise). + app.run("prefs.set", json!({"path": "rawDefaults.colorSpace", "value": "proPhoto"})).unwrap(); + (app, spec.build()) } fn mean_red(app: &PhotocraftApp) -> f32 { @@ -1807,6 +2226,58 @@ mod tests { assert!(mean_red(&app) > before + 0.05, "{before} → {}", mean_red(&app)); } + fn pixels(app: &PhotocraftApp) -> Vec { + let st = app.session.active().unwrap(); + let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + (0..32).flat_map(|y| (0..48).map(move |x| (x, y))).flat_map(|(x, y)| s.rgba(x, y)).collect() + } + + #[test] + fn the_demosaic_method_is_chosen_at_open() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0002.dng", &dng).unwrap(); + let ahd = pixels(&app); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let d = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "bilinear"}})).unwrap().unwrap(); + assert_eq!(d["openingRaw"]["demosaic"], "bilinear"); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "amaze"}})).unwrap().is_err()); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], true, "another method re-develops: {r}"); + assert!(r.get("filter").is_none(), "nothing else to apply: {r}"); + let bilinear = pixels(&app); + assert!(ahd.iter().zip(&bilinear).any(|(a, b)| (a - b).abs() > 1e-3), "bilinear differs from AHD"); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision); + + // The preference sets the method of the open itself; Open with it changes nothing. + app.run("prefs.set", json!({"path": "rawDefaults.demosaic", "value": "bilinear"})).unwrap(); + app.open_bytes("IMG_0003.dng", &dng).unwrap(); + assert_eq!(pixels(&app), bilinear, "developed with the preference's method"); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let d = app.camera_raw.as_ref().unwrap().describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview); + assert_eq!(d["openingRaw"]["demosaic"], "bilinear"); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], false, "{r}"); + + // Only the open-time dialog has the choice. + menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap(); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"demosaic": "ahd"}})).unwrap().is_err()); + + // The preference may change while a background open runs: the dialog starts from the + // method the open developed with (its note), not from the newer preference. + let warnings = app.open_bytes("IMG_0004.dng", &dng).unwrap(); + app.pending_raw_open.take(); + app.run("prefs.set", json!({"path": "rawDefaults.demosaic", "value": "ahd"})).unwrap(); + let document = app.session.active().unwrap().doc.id; + queue_open_dialog(&mut app, document, "IMG_0004.dng", None, Some(&dng), &warnings).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + use photocraft_io::raw::Demosaic; + assert_eq!((raw.developed_with, raw.demosaic), (Demosaic::Bilinear, Demosaic::Bilinear)); + } + #[test] fn shadows_of_a_raw_open_act_on_the_raw_data_and_the_preview_shows_it() { let (mut app, dng) = raw_app(); @@ -1869,12 +2340,12 @@ mod tests { assert!(app.camera_raw.is_none()); for _ in 0..3000 { crate::jobs_ui::tick(&mut app, &ctx); - if app.raw_redevelop.is_none() && !app.session.has_jobs() { + if app.raw_redevelop.is_empty() && !app.session.has_jobs() { break; } std::thread::sleep(std::time::Duration::from_millis(5)); } - assert!(app.raw_redevelop.is_none() && !app.session.has_jobs(), "the jobs never finished"); + assert!(app.raw_redevelop.is_empty() && !app.session.has_jobs(), "the jobs never finished"); let docs = app.session.documents(); assert_eq!(docs.len(), 3); assert_eq!((docs[0].doc.size.width, docs[2].doc.size.width), (10, 12), "the other tabs keep their places"); @@ -1886,6 +2357,25 @@ mod tests { assert!(mean_red(&app) > before + 0.05, "{before} → {}", mean_red(&app)); } + /// #7789: Preferences › Raw Defaults › Resolution sets a raw's ppi when the app opens it + /// itself (no background job) and from automation, and a Camera Raw re-develop keeps it. + #[test] + fn a_raw_opens_and_redevelops_at_the_raw_defaults_resolution() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.run("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap(); + app.open_bytes("IMG_0004.dng", &dng).unwrap(); + assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], true, "{r}"); + assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0, "kept by the re-develop"); + app.open_automation_bytes("IMG_0005.dng", &dng).unwrap(); + assert_eq!(app.session.active().unwrap().doc.resolution_dpi, 240.0, "automation open"); + } + #[test] fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { let (mut app, dng) = raw_app(); @@ -1901,13 +2391,422 @@ mod tests { let raw = app.pending_raw_open.take().unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); - assert_eq!(r, json!({"document": 0, "redeveloped": false})); + assert_eq!((r["document"].as_u64(), r["redeveloped"].as_bool(), r.get("filter")), (Some(0), Some(false), None), "{r}"); + assert_eq!(r.get("defaults"), None, "ProPhoto, 16-bit: no Camera Raw Default to apply: {r}"); // Preference off: raws open straight away. app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); app.open_bytes("c.dng", &dng).unwrap(); assert!(app.pending_raw_open.is_none()); // A non-raw never queues the dialog; a closed document is an error, not a panic. - assert!(open_raw(&mut app, &ctx, RawOpen { document: photocraft_doc::DocId(u64::MAX), name: "x".into(), path: None, bytes: None }).is_err()); + assert!( + open_raw( + &mut app, + &ctx, + RawOpen { + document: photocraft_doc::DocId(u64::MAX), + name: "x".into(), + path: None, + bytes: None, + developed_with: Default::default(), + demosaic: Default::default() + } + ) + .is_err() + ); + } + + #[test] + fn calibration_changes_the_preview_and_round_trips_through_xmp() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0005.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let before = app.camera_raw.as_ref().unwrap().processed.clone(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"shadowTint": 100, "calibrationHue": [100, 0, 0], "calibrationSat": [0, 0, -100]}}})) + .unwrap() + .unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.render(&ctx); + assert!( + d.processed.iter().zip(&before).any(|(a, b)| (a[0] - b[0]).abs() > 0.01 || (a[1] - b[1]).abs() > 0.01), + "the Calibration panel changes the preview" + ); + let xmp = photocraft_io::camera_raw_xmp::save(&d.params); + assert!(xmp.contains("crs:ShadowTint=\"+100\"") && xmp.contains("crs:RedHue=\"+100\"") && xmp.contains("crs:BlueSaturation=\"-100\""), "{xmp}"); + let mut back = CameraRaw::default(); + photocraft_io::camera_raw_xmp::load(&mut back, xmp.as_bytes()).unwrap(); + assert_eq!((back.shadow_tint, back.calibration_hue, back.calibration_sat), (100.0, [100.0, 0.0, 0.0], [0.0, 0.0, -100.0])); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"auto": "yes"}})).unwrap().is_err(), "auto must be a boolean"); + } + + #[test] + fn auto_sets_the_light_sliders_from_the_image() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0006.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + assert!(app.camera_raw.as_ref().unwrap().params.is_identity()); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"auto": true}})).unwrap().unwrap(); + let p = app.camera_raw.as_ref().unwrap().params.clone(); + assert!(!p.is_identity(), "Auto chose something: {p:?}"); + assert!(p.saturation == 0.0 && p.hsl_hue == [0.0; 8], "only the Light panel: {p:?}"); + // The preference does the same when a raw opens without a sidecar. + app.run("prefs.set", json!({"path": "rawDefaults.applyAutoTone", "value": true})).unwrap(); + app.open_bytes("IMG_0007.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + assert_eq!(app.camera_raw.as_ref().unwrap().params, p, "Apply auto tone opens the dialog with Auto's settings"); + } + + #[test] + fn a_sidecar_fills_the_dialog_and_open_writes_it_back() { + use std::sync::{Arc, Mutex}; + let (mut app, dng) = raw_app(); + let dir = std::env::temp_dir().join(format!("photocraft-sidecar-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let raw_path = dir.join("IMG_0008.cr2").to_string_lossy().into_owned(); + std::fs::write(&raw_path, &dng).unwrap(); + assert_eq!(sidecar_path(&raw_path), dir.join("IMG_0008.xmp").to_string_lossy()); + assert_eq!(sidecar_path("/a/b.c/noext"), "/a/b.c/noext.xmp"); + std::fs::write( + sidecar_path(&raw_path), + "", + ) + .unwrap(); + type Written = Arc)>>>; + let written: Written = Arc::default(); + let sink = written.clone(); + app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { + sink.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0008.cr2", &dng).unwrap(); + let mut raw = app.pending_raw_open.take().unwrap(); + raw.path = Some(raw_path.clone()); + open_raw(&mut app, &ctx, raw).unwrap(); + let d = app.camera_raw.as_ref().unwrap(); + assert_eq!((d.params.exposure, d.params.shadow_tint), (1.5, 20.0), "the sidecar fills the dialog"); + assert!( + app.ui.status.contains("IMG_0008.xmp") && app.ui.status.contains("Temperature"), + "the absolute white balance is reported as not supported: {}", + app.ui.status + ); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}}})).unwrap().unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 1, "Open writes the sidecar once: {:?}", w.iter().map(|(p, _)| p).collect::>()); + assert_eq!(w[0].0, sidecar_path(&raw_path)); + let text = String::from_utf8(w[0].1.clone()).unwrap(); + assert!(text.contains("crs:Exposure2012=\"+1.50\"") && text.contains("crs:Contrast2012=\"+15\"") && text.contains("crs:ShadowTint=\"+20\""), "{text}"); + // A DNG keeps its settings inside: no sidecar beside it. And the preference turns writing off. + written.lock().unwrap().clear(); + let dng_path = dir.join("IMG_0009.dng").to_string_lossy().into_owned(); + std::fs::write(&dng_path, &dng).unwrap(); + app.open_bytes("IMG_0009.dng", &dng).unwrap(); + let mut raw = app.pending_raw_open.take().unwrap(); + raw.path = Some(dng_path); + open_raw(&mut app, &ctx, raw).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); + assert!(written.lock().unwrap().is_empty(), "no sidecar for a DNG"); + app.run("prefs.set", json!({"path": "rawDefaults.writeXmpSidecars", "value": false})).unwrap(); + app.open_bytes("IMG_0008.cr2", &dng).unwrap(); + let mut raw = app.pending_raw_open.take().unwrap(); + raw.path = Some(raw_path.clone()); + open_raw(&mut app, &ctx, raw).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 30}, "commit": true}})).unwrap().unwrap(); + assert!(written.lock().unwrap().is_empty(), "the preference stops the write"); + // Without the dialog, the sidecar still develops the file. + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); + let before = app.session.documents().len(); + app.open_file(&raw_path, &dng).unwrap(); + assert!(app.pending_raw_open.is_none()); + assert_eq!(app.session.documents().len(), before + 1); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision, "opening with a sidecar leaves the document unmodified"); + assert!(mean_red(&app) > 0.0); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn camera_raw_defaults_apply_when_a_raw_opens() { + let (mut app, dng) = raw_app(); + app.run("prefs.set", json!({"values": {"rawDefaults.openInCameraRaw": false, "rawDefaults.colorSpace": "srgb", "rawDefaults.bitDepth": "8", "rawDefaults.resolution": 240.0, "rawDefaults.sharpenFor": "screen", "rawDefaults.openAsSmartObject": true}})).unwrap(); + app.open_bytes("IMG_0010.dng", &dng).unwrap(); + assert!(app.pending_raw_open.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.depth, photocraft_color::SampleType::U8, "Bit depth 8"); + assert_eq!(st.doc.resolution_dpi, 240.0, "Resolution"); + let profile = st.doc.icc_profile.as_deref().and_then(|p| photocraft_cms::Profile::parse(p).ok()).map(|p| p.description).unwrap_or_default(); + assert!(profile.to_lowercase().contains("srgb"), "Color space: {profile}"); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert!(matches!(layer.content, photocraft_doc::LayerContent::Smart(_)), "Open as Smart Object"); + assert_eq!(st.saved_revision, st.revision, "the defaults are part of opening: unmodified"); + // The dialog's Open applies them too, and reports what it applied. + app.run("prefs.set", json!({"values": {"rawDefaults.openInCameraRaw": true, "rawDefaults.openAsSmartObject": false, "rawDefaults.bitDepth": "16"}})) + .unwrap(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0011.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["defaults"], json!(["sharpenFor", "colorSpace"]), "{r}"); + assert!(r.get("filter").is_some(), "Sharpen For is a Camera Raw step: {r}"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.depth, photocraft_color::SampleType::U16); + assert_eq!(st.saved_revision, st.revision); + } + + type Written = std::sync::Arc)>>>; + + /// A sidecar as Lightroom Classic leaves one beside a raw: its own toolkit, a crop, a rating + /// and a keyword next to the develop settings (values synthetic). + const LIGHTROOM_SIDECAR: &str = r#" + + + harbour + + + +"#; + + /// `raw_app` whose `write` service records what it is asked to write, and a raw file in a fresh + /// folder with `sidecar` beside it when given. Returns the app, the raw's bytes, the folder, + /// the raw's path and the recorded writes. + fn raw_with_sidecar(tag: &str, sidecar: Option<&str>) -> (PhotocraftApp, Vec, std::path::PathBuf, String, Written) { + let (mut app, dng) = raw_app(); + let dir = std::env::temp_dir().join(format!("photocraft-sidecar-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let raw_path = dir.join("IMG_0100.cr2").to_string_lossy().into_owned(); + std::fs::write(&raw_path, &dng).unwrap(); + if let Some(text) = sidecar { + std::fs::write(sidecar_path(&raw_path), text).unwrap(); + } + let written: Written = Default::default(); + let sink = written.clone(); + app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { + sink.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + (app, dng, dir, raw_path, written) + } + + /// Opens the raw at `raw_path` in the open-time dialog, as File › Open does. + fn open_dialog_at(app: &mut PhotocraftApp, ctx: &egui::Context, dng: &[u8], raw_path: &str) { + app.open_bytes("IMG_0100.cr2", dng).unwrap(); + let mut raw = app.pending_raw_open.take().unwrap(); + raw.path = Some(raw_path.to_string()); + open_raw(app, ctx, raw).unwrap(); + } + + /// The notices about the sidecar (the open's own import notes aside). + fn notice_titles(app: &PhotocraftApp) -> Vec { + app.ui.notices.iter().map(|n| n.title.clone()).filter(|t| t.contains(".xmp")).collect() + } + + /// A Lightroom or Bridge sidecar holds settings PhotoCraft can't keep (crop, masks, rating, + /// keywords): Open uses its develop settings but never writes over it, and says so. + #[test] + fn a_foreign_sidecar_is_never_overwritten() { + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("foreign", Some(LIGHTROOM_SIDECAR)); + let ctx = egui::Context::default(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert_eq!(app.camera_raw.as_ref().unwrap().params.exposure, 1.0, "the sidecar's settings fill the dialog"); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}}})).unwrap().unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert!(written.lock().unwrap().is_empty(), "nothing written over Lightroom's sidecar"); + assert_eq!(std::fs::read_to_string(sidecar_path(&raw_path)).unwrap(), LIGHTROOM_SIDECAR); + let titles = notice_titles(&app); + assert!(titles.iter().any(|t| t.contains("IMG_0100.xmp") && t.contains("another application")), "{titles:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Bridge writes a sidecar with only a rating (no Camera Raw settings): it is not PhotoCraft's + /// to replace, with or without a change in the dialog. + #[test] + fn a_rating_only_sidecar_is_left_alone() { + let bridge = r#""#; + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("rating", Some(bridge)); + let ctx = egui::Context::default(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert!(written.lock().unwrap().is_empty(), "Open with no change writes nothing"); + assert!(notice_titles(&app).is_empty(), "and has nothing to report: {:?}", notice_titles(&app)); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 10}, "commit": true}})).unwrap().unwrap(); + assert!(written.lock().unwrap().is_empty(), "nor with a change"); + assert_eq!(std::fs::read_to_string(sidecar_path(&raw_path)).unwrap(), bridge); + let _ = std::fs::remove_dir_all(&dir); + } + + /// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing + /// back: nothing changed. + #[test] + fn opening_without_the_dialog_does_not_rewrite_the_sidecar() { + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("nodialog", Some(LIGHTROOM_SIDECAR)); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); + app.open_file(&raw_path, &dng).unwrap(); + assert!(app.pending_raw_open.is_none()); + assert_eq!(app.session.documents().len(), 1); + assert!(written.lock().unwrap().is_empty(), "just opening never writes the sidecar"); + assert_eq!(std::fs::read_to_string(sidecar_path(&raw_path)).unwrap(), LIGHTROOM_SIDECAR); + assert!(notice_titles(&app).is_empty(), "{:?}", notice_titles(&app)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A control or MCP request has no authority to write beside the user's files: its commit + /// develops the raw but writes no sidecar. The same Open from the dialog does. + #[test] + fn a_control_request_never_writes_the_sidecar() { + fn allow(_: &str, _: &Value) -> photocraft_engine::Result<()> { + Ok(()) + } + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("control", None); + app.services.automation_authorize = Some(allow); + let ctx = egui::Context::default(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + let commit = json!({"id": "filter.cameraRaw", "params": {"ui": {"set": {"contrast": 15}, "commit": true}}}); + let (req, _rx) = crate::control::ControlRequest::new("ui.menu.invoke", commit); + let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], true, "{reply}"); + assert!(app.camera_raw.is_none(), "committed"); + assert!(written.lock().unwrap().is_empty(), "no sidecar from a control request"); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); + assert_eq!(written.lock().unwrap().len(), 1, "the user's own Open writes it"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A sidecar that can't be written is reported in a notice that outlasts the status line the + /// develop and the Camera Raw step write after it. + #[test] + fn a_sidecar_write_error_stays_reported() { + let (mut app, dng, dir, raw_path, _) = raw_with_sidecar("readonly", None); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Err("read-only volume".to_string()))); + let ctx = egui::Context::default(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 0.5, "contrast": 15}, "commit": true}})).unwrap().unwrap(); + let errors: Vec<_> = app.ui.notices.iter().filter(|n| n.error).map(|n| n.title.clone()).collect(); + assert!(errors.iter().any(|t| t.contains("IMG_0100.xmp") && t.contains("read-only volume")), "{errors:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + fn open_with_settings(app: &mut PhotocraftApp, ctx: &egui::Context, dng: &[u8], name: &str) { + app.open_bytes(name, dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(app, ctx, raw).unwrap(); + menu(app, ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 0.5, "contrast": 60, "saturation": 40, "vibrance": 30}}})).unwrap().unwrap(); + } + + fn profile_name(app: &PhotocraftApp) -> String { + let st = app.session.active().unwrap(); + st.doc.icc_profile.as_deref().and_then(|p| photocraft_cms::Profile::parse(p).ok()).map(|p| p.description).unwrap_or_default() + } + + /// Camera Raw renders in its own space and converts to the workflow space last, so Open gives + /// what the dialog previewed on the developed ProPhoto pixels: with the default Adobe RGB the + /// result is the Camera Raw step on the ProPhoto develop, then the conversion. Inline and with + /// the re-develop as a background job. + #[test] + fn the_camera_raw_step_runs_before_the_workflow_conversion() { + let ctx = egui::Context::default(); + let (mut app, dng) = raw_app(); + open_with_settings(&mut app, &ctx, &dng, "a.dng"); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + app.run("edit.convertToProfile", json!({"profile": "adobe-rgb-compat", "intent": "relative"})).unwrap(); + let want = pixels(&app); + let close = |a: &[f32], b: &[f32]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5); + for background in [false, true] { + let (mut app, dng) = raw_app(); + app.run("prefs.set", json!({"path": "rawDefaults.colorSpace", "value": "adobeRgb"})).unwrap(); + open_with_settings(&mut app, &ctx, &dng, "b.dng"); + app.background_jobs = background; + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + if background { + assert_eq!(r["pending"], true, "the re-develop runs as a job: {r}"); + } else { + assert_eq!(r["defaults"], json!(["colorSpace"]), "{r}"); + } + for _ in 0..3000 { + crate::jobs_ui::tick(&mut app, &ctx); + if app.raw_redevelop.is_empty() && !app.session.has_jobs() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(app.raw_redevelop.is_empty() && !app.session.has_jobs(), "the jobs never finished"); + assert!(profile_name(&app).to_lowercase().contains("adobe"), "background {background}: {}", profile_name(&app)); + assert!(close(&pixels(&app), &want), "background {background}: the step ran on other pixels than the preview"); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision, "background {background}: unmodified"); + } + } + + /// Two raws opened one after the other while the first one's re-develop still runs in the + /// background: each lands with its Camera Raw step and conversions and is left unmodified. + #[test] + fn two_raws_opened_back_to_back_both_finish_their_open() { + let ctx = egui::Context::default(); + let (mut app, dng) = raw_app(); + app.run("prefs.set", json!({"path": "rawDefaults.colorSpace", "value": "adobeRgb"})).unwrap(); + open_with_settings(&mut app, &ctx, &dng, "a.dng"); + app.background_jobs = true; + let a = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(a["pending"], true, "the re-develop runs as a job: {a}"); + app.background_jobs = false; + open_with_settings(&mut app, &ctx, &dng, "b.dng"); + app.background_jobs = true; + let b = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(b["pending"], true, "{b}"); + for _ in 0..3000 { + crate::jobs_ui::tick(&mut app, &ctx); + if app.raw_redevelop.is_empty() && !app.session.has_jobs() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(app.raw_redevelop.is_empty() && !app.session.has_jobs(), "the jobs never finished"); + assert_eq!(app.session.documents().len(), 2); + for i in 0..2 { + app.session.set_active(i); + assert!(profile_name(&app).to_lowercase().contains("adobe"), "document {i}: {}", profile_name(&app)); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision, "document {i} unmodified"); + } + } + + /// A sidecar is read only from a regular file of at most `MAX_XMP_BYTES`: a folder, or a file + /// one byte larger, is not read (a pipe or device named like one would never end). + #[test] + fn sidecars_are_read_from_small_regular_files_only() { + let dir = std::env::temp_dir().join(format!("photocraft-sidecar-bounded-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("folder.xmp")).unwrap(); + let cap = photocraft_io::camera_raw_xmp::MAX_XMP_BYTES; + let (fits, larger) = (dir.join("fits.xmp"), dir.join("larger.xmp")); + std::fs::write(&fits, vec![b' '; cap]).unwrap(); + std::fs::write(&larger, vec![b' '; cap + 1]).unwrap(); + let s = |p: std::path::PathBuf| p.to_string_lossy().into_owned(); + assert_eq!(read_bounded(&s(fits)).map(|b| b.len()), Some(cap)); + assert_eq!(read_bounded(&s(larger.clone())), None); + assert_eq!(read_bounded(&s(dir.join("folder.xmp"))), None); + assert_eq!(read_bounded(&s(dir.join("missing.xmp"))), None); + assert_eq!(existing_sidecar_is_ours(&s(dir.join("folder.xmp"))), Some(false), "something is there: never written over"); + assert_eq!(existing_sidecar_is_ours(&s(larger)), Some(false)); + assert_eq!(existing_sidecar_is_ours(&s(dir.join("missing.xmp"))), None); + let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -1918,7 +2817,7 @@ mod tests { let written: Written = Arc::default(); let w = written.clone(); let foreign = br#" - + "#; let answers = vec![ Some(FileDialogAnswer::SaveTo("/out/Look.xmp".into())), @@ -1974,3 +2873,7 @@ mod tests { assert_eq!(app.camera_raw.as_ref().unwrap().params.clarity, 25.0); } } + +#[cfg(test)] +#[path = "camera_raw_identity_tests.rs"] +mod identity_tests; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5f1dd941c2..0a8ea4aff5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,14 +12,74 @@ use serde_json::json; use crate::PhotocraftApp; use crate::state::{Tool, View}; -/// Largest texture side we upload; bigger documents display downsampled until the GPU path lands. +/// Largest texture side we upload; bigger documents are uploaded downsampled, with the part in +/// view added at the screen's resolution when zoomed in (`detail_plan`). pub const MAX_TEXTURE: u32 = 4096; /// The largest texture side to upload on this device: [`MAX_TEXTURE`], or less where the GPU /// allows less (2048 on some older or low-end GPUs and WebGL contexts). Past the device's limit /// egui asserts and the backend fails to create the texture. fn texture_side(ctx: &egui::Context) -> u32 { - u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE) + clamp_texture_side(ctx.input(|i| i.max_texture_side)) +} + +/// [`texture_side`] for a device whose largest texture side is `max_texture_side`. +fn clamp_texture_side(max_texture_side: usize) -> u32 { + u32::try_from(max_texture_side).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE) +} + +/// How many document pixels per side one texel of the CPU canvas texture of a `size` document +/// holds when textures are at most `side` texels per side: 1 (full resolution) when it fits. +fn texture_factor(size: [u32; 2], side: u32) -> u32 { + size[0].max(size[1]).div_ceil(side.max(1)).max(1) +} + +/// What the CPU canvas draws over its reduced texture so a large document shows its own pixels +/// at 100 % (#7568): the part of the document in view (`region`, document pixels) composited at +/// one texel per `factor` pixels, a power of two no coarser than the screen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DetailPlan { + pub factor: u32, + pub region: DRect, +} + +/// The detail region snaps outward to multiples of this many texels, so a small pan reuses it. +const DETAIL_QUANTUM: i32 = 256; + +/// The detail texture a CPU canvas view of a `size` document needs, at `zoom` physical pixels +/// per document pixel over `visible` (document pixels), with textures at most `side` texels per +/// side: `None` when the whole-document texture is already as fine as the screen (a document +/// that fits, or a view zoomed out far enough), or nothing of the document is in view. +/// +/// A document over `side` (4096 px, or the device's smaller limit) is uploaded reduced, so at +/// 100 % it was drawn magnified and blurred. The detail costs what the window shows, not what the +/// document holds: its side is capped by `side` like any texture, coarsening by powers of two. +pub(crate) fn detail_plan(size: [u32; 2], side: u32, zoom: f32, visible: DRect) -> Option { + if !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let base = texture_factor(size, side); + let mut factor = crate::adjust_preview::proxy_factor(zoom); + let bounds = DRect::new(0, 0, i32::try_from(size[0]).ok()?, i32::try_from(size[1]).ok()?); + let v = visible.intersect(&bounds); + if v.is_empty() { + return None; + } + while factor < base { + let q = DETAIL_QUANTUM.saturating_mul(i32::try_from(factor).unwrap_or(i32::MAX)); + let up = |x: i32| x.div_euclid(q).saturating_add(i32::from(x.rem_euclid(q) != 0)).saturating_mul(q); + let region = DRect::new(v.x0.div_euclid(q).saturating_mul(q), v.y0.div_euclid(q).saturating_mul(q), up(v.x1), up(v.y1)).intersect(&bounds); + // A reduced region's texel count can round one up (`render_reduced_damage`'s cells). + let fits = |n: u32| n.div_ceil(factor).saturating_add(1) <= side; + // Snapped when that fits, else just what is in view (a window near the device's limit). + for region in [region, v] { + if !region.is_empty() && fits(region.width()) && fits(region.height()) { + return Some(DetailPlan { factor, region }); + } + } + factor = factor.saturating_mul(2); + } + None } pub(crate) struct ClonePreviewCache { @@ -95,10 +155,10 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie let image = display_image(display.as_deref(), &buf); match app.clone_preview.as_mut() { Some(cache) => { - cache.texture.set(image, TextureOptions::LINEAR); + cache.texture.set(image, canvas_texture_options(1)); cache.key = key; } - None => app.clone_preview = Some(ClonePreviewCache { key, texture: painter.ctx().load_texture("clone-preview", image, TextureOptions::LINEAR) }), + 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 }; @@ -142,8 +202,49 @@ pub struct CanvasCache { /// `revision`, so the Navigator never shows a stale CPU image after GPU-path edits. pub tex_revision: u64, pub tex_preview_key: u64, + /// All uploaded texels are opaque. Screen coverage is checked separately before omitting + /// the checker. Updated with uploads, never by scanning during an unchanged frame. + pub texture_opaque: bool, /// The CPU path's pixel grid for the part of the document in view (pixel_grid.rs). pub grid: Option, + /// The CPU path's sharper textures of the part in view, over a reduced `texture` (#7568), + /// by `InView::key` (two windows of a document can show different parts). + pub detail: Vec<(u64, DetailTexture)>, +} + +/// [`CanvasCache::detail`]: the part of the document `DetailPlan` chose, uploaded at its factor. +pub struct DetailTexture { + texture: egui::TextureHandle, + plan: DetailPlan, + /// The texels it holds, in the plan's reduced pixels (`render_reduced_damage`'s output rect). + out: DRect, + /// Revision and preview key (display key folded in) it was rendered at. + revision: u64, + preview_key: u64, + /// Pixels were composited for it this call (for the perf span). + rendered: bool, +} + +/// The CPU canvas textures to draw: the whole document, and over it the sharper part in view. +pub(crate) struct CpuTextures { + pub texture: egui::TextureId, + pub scale: f32, + /// The document area the texture covers: the document's bounds, or for a filter preview that + /// changes the canvas's dimensions (Image › Rotation) its result centred on the document, as + /// the GPU canvas draws it. + pub bounds: DRect, + /// The detail texture and the document area it covers (x0, y0, x1, y1 in document pixels). + pub detail: Option<(egui::TextureId, [f32; 4])>, +} + +/// The part of a CPU canvas view in sight: its document area and zoom (physical pixels per +/// document pixel), for [`ensure_texture_in_view`]'s detail texture. +#[derive(Clone, Copy, Debug)] +pub(crate) struct InView { + pub visible: DRect, + pub zoom: f32, + /// Which view this is, so each keeps its own detail texture. + pub key: u64, } /// An in-progress pointer gesture on the canvas. @@ -172,6 +273,9 @@ pub struct Drag { /// `Some(false)` moves the outline, `Some(true)` moves the floating piece (`select.float`), as /// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels_with`). pub sel_move: Option, + /// The last ⇧-locked offset of a selection-tool drag of the selection (`move_mods`, #6231); + /// 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, } @@ -189,6 +293,7 @@ impl Drag { released: egui::Modifiers::NONE, reposition: false, sel_move: None, + sel_axis: None, lasso: None, } } @@ -230,28 +335,37 @@ impl Drag { /// style (fixed ratio / fixed size), ⇧ for a square or circle, ⌥ to draw from the centre. pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -> ([f64; 2], [f64; 2]) { let (shift, alt) = d.marquee_mods(); + if o.marquee_style == "fixedSize" { + // A fixed box is positioned, not sized, by the drag. Using the press plus the drag's + // sign pinned it there and flipped it by its full width/height when the pointer crossed + // the press. The preview and release both follow the current pointer instead. + let (w, h) = (f64::from(o.marquee_width).max(1.0), f64::from(o.marquee_height).max(1.0)); + let a = if alt { [end[0] - w / 2.0, end[1] - h / 2.0] } else { end }; + return (a, [a[0] + w, a[1] + h]); + } let e = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end); if !alt { return (d.start, e); } let (dx, dy) = (e[0] - d.start[0], e[1] - d.start[1]); - // A fixed size is centred on the press point; otherwise the drag is the half extent. - let (hx, hy) = if o.marquee_style == "fixedSize" { (dx / 2.0, dy / 2.0) } else { (dx, dy) }; - ([d.start[0] - hx, d.start[1] - hy], [d.start[0] + hx, d.start[1] + hy]) + // For a sized marquee the drag is the half extent when drawing from the centre. + ([d.start[0] - dx, d.start[1] - dy], [d.start[0] + dx, d.start[1] + dy]) } /// The pixel rectangle a Rectangular/Elliptical Marquee drag selects so far: shown live (ants /// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop. `None` /// while the gesture is still a click (it never left the press point by more than a few screen /// pixels at `zoom`, points per document pixel) or the rectangle has no area: a release then -/// deselects. A one-pixel row or column is a selection (#2391). +/// deselects. A one-pixel row or column is a selection (#2391). With the Fixed Size style a +/// click places the selection, as in Photoshop. pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: f32) -> Option<[f64; 4]> { - if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || crate::shape_dialog::is_click(d.start, &d.points, zoom) { + let fixed = o.marquee_style == "fixedSize"; + if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || (!fixed && crate::shape_dialog::is_click(d.start, &d.points, zoom)) { return None; } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let (a, b) = marquee_corners(o, d, last); - let mut r = marquee_px(a, b); + let mut r = if fixed { fixed_size_px(a, b, o.marquee_width, o.marquee_height) } else { marquee_px(a, b) }; if marquee_is_square(o, d) { r = square_px(r, d.start, last); } @@ -263,6 +377,16 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()] } +/// The pixel rectangle of a Fixed Size marquee between the corners `a` and `b`: exactly `w` × `h` +/// whole pixels. Snapping both edges outwards (`marquee_px`) added a pixel to each side whenever +/// the press point sat between pixels, so 10 × 10 selected 11 × 11 (#6015). The near edge goes +/// to the closest pixel edge and the far one is the size away. +pub fn fixed_size_px(a: [f64; 2], b: [f64; 2], w: f32, h: f32) -> [f64; 4] { + let (w, h) = (f64::from(w).round().max(1.0), f64::from(h).round().max(1.0)); + let (x0, y0) = (a[0].min(b[0]).round(), a[1].min(b[1]).round()); + [x0, y0, x0 + w, y0 + h] +} + /// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with /// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`. fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool { @@ -329,23 +453,20 @@ fn single_line_drag_px(app: &PhotocraftApp, d: &Drag) -> Option<[f64; 4]> { /// /// A rotated view projects the rectangle onto a rotated one, and an axis-aligned screen box /// through two of its corners is a different shape in a different place: the commit was always -/// right, only the hinting was drawn elsewhere (#2512). Every point is snapped to the half-pixel -/// grid like every other ants outline (`marching_ants_segments`), so the lines stay crisp at any -/// zoom (#172). +/// right, only the hinting was drawn elsewhere (#2512). The points are not snapped: the ants snap +/// straight edges to device pixels as they draw them (`crate::ants`), like every other outline, so +/// the lines stay crisp at any zoom (#172) and display scale (#7489). pub(crate) fn marquee_ants(xf: &ViewXform, tool: Tool, a: [f64; 2], b: [f64; 2]) -> Vec { let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0])); let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1])); - let at = |x: f64, y: f64| { - let p = xf.to_screen(x as f32, y as f32); - pos2(p.x.round() + 0.5, p.y.round() + 0.5) - }; + let at = |x: f64, y: f64| xf.to_screen(x as f32, y as f32); if tool != Tool::EllipseMarquee { return vec![at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)]; } // An axis-aligned view (rotation a multiple of 90 degrees) maps the ellipse onto the one - // inscribed in the snapped screen box of two opposite corners. Draw that, unsnapped: snapping - // each vertex on its own turns the nearly flat arcs at the edge midpoints into 1 px stairs, - // whose antialiased steps wash the ants out to grey on white and black. + // inscribed in the screen box of two opposite corners. The ants draw its vertices where they + // are: snapping each on its own turns the nearly flat arcs at the edge midpoints into 1 px + // stairs, whose antialiased steps wash the ants out to grey on white and black. let quarter = xf.rotation.rem_euclid(90.0); if quarter < 1e-4 || 90.0 - quarter < 1e-4 { let r = Rect::from_two_pos(at(x0, y0), at(x1, y1)); @@ -429,22 +550,27 @@ pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor let labels = labels.map(|label| tl!(label)); let (lw, vw) = (labels.map(width), values.each_ref().map(|v| width(v))); let (label_col, value_col) = (lw.iter().fold(0.0f32, |a, &b| a.max(b)), vw.iter().fold(0.0f32, |a, &b| a.max(b))); - egui::Area::new(egui::Id::new(id)).order(egui::Order::Tooltip).fixed_pos(cursor + vec2(16.0, 18.0)).interactable(false).constrain(true).show(ctx, |ui| { - egui::Frame::new().fill(t.card).stroke(Stroke::new(1.0, t.card_border)).corner_radius(t.radius_sm).inner_margin(egui::Margin::symmetric(7, 4)).show( - ui, - |ui| { - ui.spacing_mut().item_spacing = vec2(0.0, 1.0); - for (i, value) in values.into_iter().enumerate() { - // Labels left, values right-aligned on one edge, sharing a baseline. - ui.horizontal(|ui| { - ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend()); - ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]); - ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend()); - }); - } - }, - ); - }); + egui::Area::new(egui::Id::unique(id)).order(egui::Order::Tooltip).fixed_pos(cursor + vec2(16.0, 18.0)).interactable(false).constrain(true).show( + ctx, + |ui| { + egui::Frame::new() + .fill(t.card) + .stroke(Stroke::new(1.0, t.card_border)) + .corner_radius(t.radius_sm) + .inner_margin(egui::Margin::symmetric(7, 4)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 1.0); + for (i, value) in values.into_iter().enumerate() { + // Labels left, values right-aligned on one edge, sharing a baseline. + ui.horizontal(|ui| { + ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend()); + ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]); + ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend()); + }); + } + }); + }, + ); } /// The engine's live stroke of a tool: the Brush, Pencil and Eraser paint dabs; the Clone Stamp, @@ -453,6 +579,7 @@ enum EngineStroke { Brush(Box), Retouch(photocraft_engine::retouch_cmds::LiveRetouch), Dab(photocraft_engine::retouch_cmds::LiveDab), + Mixer(Box), } impl EngineStroke { @@ -461,6 +588,7 @@ impl EngineStroke { fn wants_time(&self) -> bool { match self { EngineStroke::Brush(l) => l.wants_time(), + EngineStroke::Mixer(l) => l.wants_time(), EngineStroke::Retouch(_) | EngineStroke::Dab(_) => false, } } @@ -470,6 +598,7 @@ impl EngineStroke { Self::Brush(s) => &s.doc, Self::Retouch(s) => &s.doc, Self::Dab(s) => &s.doc, + Self::Mixer(s) => &s.doc, } } fn bounds(&self) -> DRect { @@ -477,6 +606,7 @@ impl EngineStroke { Self::Brush(s) => s.bounds(), Self::Retouch(s) => s.bounds(), Self::Dab(s) => s.bounds(), + Self::Mixer(s) => s.bounds(), } } fn push(&mut self, pts: &[photocraft_engine::paint::StrokePoint]) -> photocraft_engine::Result { @@ -484,12 +614,14 @@ impl EngineStroke { Self::Brush(s) => s.push(pts), Self::Retouch(s) => s.push(pts), Self::Dab(s) => s.push(pts), + Self::Mixer(s) => s.push(pts), } } /// The jitter seed the commit must reuse (the Clone Stamp's comes from the session brush). fn seed(&self) -> Option { match self { Self::Brush(s) => Some(s.seed), + Self::Mixer(s) => Some(s.seed), Self::Retouch(_) => None, Self::Dab(_) => None, } @@ -499,6 +631,7 @@ impl EngineStroke { /// A stroke shown while it is drawn: the engine renders the real result onto a copy of the /// document, and the canvas redraws only what each step changed. pub(crate) struct LiveStroke { + preview_failed: bool, stroke: EngineStroke, doc: photocraft_doc::DocId, revision: u64, @@ -585,6 +718,7 @@ pub(crate) fn strokes_live(tool: Tool) -> bool { tool, Tool::Brush | Tool::Pencil + | Tool::MixerBrush | Tool::Eraser | Tool::ColorReplacement | Tool::Blur @@ -681,11 +815,13 @@ pub(crate) fn eyedropper_ring_colors(app: &mut PhotocraftApp, x: f64, y: f64) -> Some((display_color(new), display_color([fg[0], fg[1], fg[2]]))) } -/// Draw the Eyedropper's comparison ring at `p`: the sampled colour on the upper arc, the -/// current one below, both over a thin dark outline so they read on any image (#213). Like -/// Photoshop it shows only while the mouse button is held (`held`): hovering shows the plain -/// crosshair and samples nothing. `None` when not held, there is nothing to sample, or the -/// Precise-cursor preference wants the plain crosshair. +/// Draw the Eyedropper's comparison ring at `p`: an opaque neutral grey band around the +/// pointer with the sampled colour on its upper half and the current one on the lower, +/// framed by thin dark and light hairlines so every edge reads on any image, and the +/// pipette at its centre marking the sampled pixel (#213, #6973). Like Photoshop it shows +/// only while the mouse button is held (`held`): hovering shows the plain pipette and +/// samples nothing. `None` when not held, there is nothing to sample, or the Precise-cursor +/// preference wants the plain crosshair. fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, p: Pos2, held: bool) -> Option { // The options bar's Show Sampling Ring turns it off (#1649). if !held || !app.ui.tool_options.eyedropper_ring || app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { @@ -693,32 +829,49 @@ fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXf } let [x, y] = xf.to_doc(p); let (new, current) = eyedropper_ring_colors(app, x, y)?; - // Two arcs with a small gap at 3 and 9 o'clock. Screen y grows down, so PI..TAU is the - // upper half (the new colour) and 0..PI the lower (the current one). + // Photoshop's proportions (#6973): a ring about 210 pt across whose opaque grey outer + // band and coloured inner band are each about 17 pt wide, over a transparent centre. + // In points, so the ring scales with Interface › UI Scale like the rest of the cursor. + let (r_out, band) = (105.0_f32, 17.0_f32); + let r_in = r_out - 2.0 * band; + // Screen y grows down, so PI..TAU is the upper half (the sampled colour) and 0..PI the + // lower (the current one); the halves meet at 3 and 9 o'clock, as in Photoshop. use std::f32::consts::{PI, TAU}; - let (r, w, gap) = (11.0_f32, 4.0_f32, 0.16_f32); - let arc = |a0: f32, a1: f32| -> Vec { - (0..=24) - .map(|i| { - let a = a0 + (a1 - a0) * i as f32 / 24.0; - p + vec2(a.cos(), a.sin()) * r - }) - .collect() - }; - let outline = crate::theme::Tokens::get(painter.ctx()).shadow; - for (a0, a1, colour) in [(PI + gap, TAU - gap, new), (gap, PI - gap, current)] { - let points = arc(a0, a1); - painter.add(egui::Shape::line(points.clone(), Stroke::new(w + 1.5, outline))); - painter.add(egui::Shape::line(points, Stroke::new(w, colour))); - } - Some(egui::CursorIcon::None) + let upper: Vec = (0..=48) + .map(|i| { + let a = PI + (TAU - PI) * i as f32 / 48.0; + p + vec2(a.cos(), a.sin()) * (r_in + band / 2.0) + }) + .collect(); + // `circle_stroke` paints its stroke outside the radius, `Shape::line` centres it. + painter.circle_stroke(p, r_in, Stroke::new(band, current)); + painter.add(egui::Shape::line(upper, Stroke::new(band, new))); + painter.circle_stroke(p, r_in + band, Stroke::new(band, RING_GREY)); + // Thin dark and light hairlines on the ring's two edges, one physical pixel wide, so it + // reads on any colour including a flat mid-grey that matches the band itself (#6973). + let px = 1.0 / painter.ctx().pixels_per_point().max(1.0); + let (dark, light) = (Stroke::new(px, Color32::from_black_alpha(160)), Stroke::new(px, Color32::from_white_alpha(160))); + painter.circle_stroke(p, r_out, dark); + painter.circle_stroke(p, r_out + px, light); + painter.circle_stroke(p, r_in - px, dark); + painter.circle_stroke(p, r_in - 2.0 * px, light); + // Photoshop keeps the pipette visible at the centre, its tip on the sampled pixel; a + // ring this size says nothing about where it samples without it. + Some(pipette_cursor(painter.ctx(), p)) } +/// The Eyedropper ring's opaque neutral grey, as Photoshop measures it (#6973). +const RING_GREY: Color32 = Color32::from_gray(127); + fn begin_live_stroke(app: &PhotocraftApp) -> Option { static STROKES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let st = app.session.active()?; let d = app.drag.as_ref()?; - let stroke = if d.tool == Tool::ColorReplacement { + let stroke = if d.tool == Tool::MixerBrush { + let points = app.stylus.stroke_points(&d.points); + let p = stroke_params(app, d, points.get(..1)?); + EngineStroke::Mixer(Box::new(photocraft_engine::brush_cmds::mixer_live::LiveMixer::begin(&app.session, &p).ok()?)) + } else if d.tool == Tool::ColorReplacement { // The params `retouch_ui::finish_stroke` commits. let mut p = crate::retouch_ui::color_replacement_params(app); p["points"] = json!(d.points); @@ -744,7 +897,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { EngineStroke::Dab(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 { json!({}) } else { crate::retouch_ui::clone_params(app)? }; + 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()?) @@ -754,7 +907,30 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { }; let n = STROKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) & 0xff_ffff; let damage = vec![stroke.bounds()]; - Some(LiveStroke { stroke, doc: st.doc.id, revision: st.revision, key: (1 << 44) | (n << 20), damage, fed: d.points.len() }) + Some(LiveStroke { + stroke, + doc: st.doc.id, + revision: st.revision, + key: (1 << 44) | (n << 20), + damage, + fed: if d.tool == Tool::MixerBrush { 1 } else { d.points.len() }, + preview_failed: false, + }) +} + +/// Cancel uncommitted mixer paint when its document, tool or input context goes away. +pub(crate) fn cancel_mixer_on_context_change(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + if !app.drag.as_ref().is_some_and(|drag| drag.tool == Tool::MixerBrush) { + return false; + } + let changed = app.ui.tool != Tool::MixerBrush + || app.live_stroke.as_ref().is_some_and(|live| !app.session.active().is_some_and(|st| st.doc.id == live.doc && st.revision == live.revision)); + if changed || ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + app.drag = None; + app.live_stroke = None; + return true; + } + false } /// Render the drag points the live stroke hasn't seen yet, with the pen pressure, tilt and @@ -766,6 +942,9 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { return; } let (Some(l), Some(d)) = (app.live_stroke.as_mut(), app.drag.as_ref()) else { return }; + if l.preview_failed { + return; + } let pose = &app.stylus.stroke; // The same times `stroke_points` gives the commit (from the drag's first point). let t0 = app.stylus.point_ms(0); @@ -786,7 +965,17 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { let started = crate::gpu_canvas::now_ms(); match l.stroke.push(&pts) { Ok(r) => l.damage.push(r), - Err(_) => app.live_stroke = None, + Err(e) => { + if d.tool == Tool::MixerBrush { + // Keep the last valid preview and its document/revision guard. All input + // stays in the drag; release replays it without the preview tile budget. + l.preview_failed = true; + app.ui.status = e.to_string(); + app.ui.status_error = true; + } else { + app.live_stroke = None; + } + } } app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); } @@ -800,16 +989,32 @@ pub(crate) fn live_retouch_points(app: &PhotocraftApp) -> Option = d.points.get(fed..).unwrap_or_default().iter().map(|q| photocraft_engine::paint::StrokePoint::new(q[0], q[1], q[2] as f32)).collect(); - if rest.is_empty() || live.push(&rest).is_ok() { - let _ = app.session.offer_live_retouch(live); + // A refused offer (the brush changed mid-stroke, say) leaves the commit to render the stroke. + match stroke { + EngineStroke::Retouch(mut live) if !dab => { + let ready = rest.is_empty() || live.push(&rest).is_ok(); + if ready { + let _ = app.session.offer_live_retouch(live); + } + } + EngineStroke::Dab(mut live) if dab => { + let ready = rest.is_empty() || live.push(&rest).is_ok(); + if ready { + let _ = app.session.offer_live_dab(live); + } + } + _ => {} } } @@ -821,9 +1026,16 @@ fn tap_at_peak_pressure(app: &mut PhotocraftApp) { return; } let peak = f64::from(app.stylus.tap_pressure()); - if let Some(p) = app.drag.as_mut().filter(|d| d.points.len() == 1).and_then(|d| d.points.first_mut()) { + let mut refresh_mixer = false; + if let Some(d) = app.drag.as_mut().filter(|d| d.points.len() == 1) + && let Some(p) = d.points.first_mut() + { + refresh_mixer = d.tool == Tool::MixerBrush && peak > p[2]; p[2] = p[2].max(peak); } + if refresh_mixer { + app.live_stroke = begin_live_stroke(app); + } } /// While a stroke is held still, its point repeats this often (in ms) so time-based brush @@ -860,6 +1072,7 @@ pub(crate) fn freehand_tool(tool: Tool) -> bool { | Tool::BackgroundEraser | Tool::HistoryBrush | Tool::Remove + | Tool::ArtHistoryBrush | Tool::SpotHealing | Tool::Healing | Tool::CloneStamp @@ -938,14 +1151,54 @@ impl ViewXform { /// The main canvas's mapping for the active document as last laid out (inside the rulers). pub fn active(app: &PhotocraftApp) -> Option { let v = app.ui.views.get(app.session.active_index()?)?; - Some(Self { - rect: crate::rulers::content_rect(app, app.last_canvas_rect), - zoom: v.zoom / app.canvas_ppp(), - center: v.center, - flip: app.ui.view.flip_horizontal, - rotation: v.rotation, - aspect: app.ui.view.display_aspect(), - }) + let (zoom, center) = + if v.zoom_animating && app.session.prefs().general.animated_zoom { v.display_camera.unwrap_or((v.zoom, v.center)) } else { (v.zoom, v.center) }; + Some( + Self { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: zoom / app.canvas_ppp(), + center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + } + .pixel_aligned_for(app, app.canvas_ppp()), + ) + } + + fn pixel_aligned_for(self, app: &PhotocraftApp, ppp: f32) -> Self { + // Default Crop counter-moves the frame and camera with fractional pointer deltas. + // Preserve that continuous mapping so its fixed frame does not wobble during a drag. + if crate::crop_mode::moves_image(app) { self } else { self.pixel_aligned(ppp) } + } + + /// At integer physical magnifications, texel edges must land on display pixel edges. + /// Keep this derived camera shared by images, overlays and input without changing the saved pan. + fn pixel_aligned(mut self, ppp: f32) -> Self { + if !ppp.is_finite() || ppp <= 0.0 || !self.zoom.is_finite() || self.zoom <= 0.0 || !self.rotation.is_finite() || self.rotation.abs() >= 1e-8 { + return self; + } + let scale = vec2(self.zoom_x(), self.zoom) * ppp; + if !scale.is_finite() + || scale.x.round() < 1.0 + || scale.y.round() < 1.0 + || (scale.x - scale.x.round()).abs() > 1e-5 + || (scale.y - scale.y.round()).abs() > 1e-5 + { + return self; + } + let origin = self.to_screen(0.0, 0.0); + let physical = origin * ppp; + if !physical.is_finite() { + return self; + } + let aligned = pos2(physical.x.round(), physical.y.round()) / ppp; + let offset = self.unmap_vec((aligned - origin) / self.zoom); + let center = [self.center[0] - offset.x, self.center[1] - offset.y]; + if center.iter().all(|v| v.is_finite()) { + self.center = center; + } + self } fn sincos(self) -> (f32, f32) { @@ -1048,6 +1301,54 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32, aspect: f view.fill_pending = false; } +/// Ease a transient presentation camera over 220 ms without changing the requested camera. +fn animated_view(view: &mut View, xf: ViewXform, ppp: f32, animated_zoom: bool, time: f64) -> View { + if !animated_zoom { + view.request_zoom_animation(false); + } + let target = (view.zoom, view.center); + // Clamping, a canvas resize, or another camera command can make the original anchor + // incompatible with the requested camera. The target must always win. + if let Some((doc, screen)) = view.zoom_anchor { + let offset = xf.unmap_vec((pos2(screen[0], screen[1]) - xf.rect.center()) / (view.zoom / ppp)); + if (doc[0] as f32 - offset.x - view.center[0]).abs() > 0.01 || (doc[1] as f32 - offset.y - view.center[1]).abs() > 0.01 { + view.zoom_anchor = None; + } + } + let mut shown = view.clone(); + if view.zoom_animating { + if view.zoom_transition.is_none_or(|a| a.target != target) { + view.zoom_transition = Some(crate::state::ZoomTransition { from: view.display_camera.unwrap_or(target), target, started: time }); + } + if let Some(a) = view.zoom_transition { + let elapsed = (time - a.started) / 0.22; + let t = if elapsed.is_finite() { elapsed.clamp(0.0, 1.0) as f32 } else { 1.0 }; + let ease = t * t * (3.0 - 2.0 * t); + let lerp = |from: f32, to: f32| from + (to - from) * ease; + shown.zoom = lerp(a.from.0, target.0); + shown.center = [lerp(a.from.1[0], target.1[0]), lerp(a.from.1[1], target.1[1])]; + if let Some((doc, screen)) = view.zoom_anchor { + let zoom = shown.zoom / ppp; + if zoom.is_finite() && zoom > 0.0 { + let offset = xf.unmap_vec((pos2(screen[0], screen[1]) - xf.rect.center()) / zoom); + shown.center = [doc[0] as f32 - offset.x, doc[1] as f32 - offset.y]; + } + } + if t >= 1.0 { + view.zoom_animating = false; + } + } + } + if !view.zoom_animating { + shown.zoom = target.0; + shown.center = target.1; + view.zoom_anchor = None; + view.zoom_transition = None; + } + view.display_camera = Some((shown.zoom, shown.center)); + shown +} + /// Centre the document and zoom until it fills the canvas. One axis can extend beyond the /// viewport, matching the Hand tool's Fill Screen action. pub fn fill_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32, aspect: f32) { @@ -1185,6 +1486,9 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::type_font_preview::display_doc(app, idx) { return shown; } + // Vanishing Point's strokes over the document as it is now (the Layers panel may have + // changed it since the options bar synced them this frame). + crate::vp_ui::sync_preview(app); // Puppet / Perspective Warp previews hide the layer they draw on a mesh. if let Some(shown) = crate::distort_ui::display_doc(app, idx) { return shown; @@ -1225,6 +1529,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar } if let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) && app.session.active_index() == Some(idx) + && pv.doc.id == st.doc.id && st.doc.layer(photocraft_doc::LayerId(t.layer)).is_some() { return (pv.doc.clone(), (1 << 40) + pv.session); @@ -1339,7 +1644,7 @@ pub fn navigator_gpu_view(app: &PhotocraftApp, idx: usize, rect: Rect, ctx: &egu rotation: 0.0, shadow: false, pixel_grid: false, - view_key: egui::Id::new(("pc-navigator", ctx.viewport_id(), idx)).value(), + view_key: egui::Id::unique(("pc-navigator", ctx.viewport_id(), idx)).value(), offset: [0.0, 0.0], ..params }) @@ -1360,7 +1665,7 @@ pub fn navigator_waits_for_gpu(app: &PhotocraftApp, idx: usize, ctx: &egui::Cont if app.canvases.keys().any(|(d, _)| *d == id) { return false; } - let key = egui::Id::new(("navigator-waits", id.0)); + let key = egui::Id::unique(("navigator-waits", id.0)); let waited = ctx.data(|d| d.get_temp::(key)).unwrap_or(0); if waited >= NAVIGATOR_WAIT_FRAMES { return false; @@ -1448,12 +1753,62 @@ fn canvas_texture_options(factor: u32) -> TextureOptions { /// Make sure the canvas texture for document `idx` on `display` is current; returns (texture /// id, scale). pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, display: Option) -> Option<(egui::TextureId, f32)> { + ensure_texture_in_view(app, ctx, idx, display, None).map(|t| (t.texture, t.scale)) +} + +/// The document area a filter preview of `preview` size covers, for a `doc` sized document +/// previewed at proxy factor `k`: the document's bounds when it is the document's size (reduced by +/// `k`), else its own size (times `k`) centred on the document's centre, as the GPU canvas centres +/// a size-changing preview (Image › Rotation). +fn preview_bounds(doc: photocraft_geom::Size, preview: photocraft_geom::Size, k: u32) -> DRect { + let k = k.max(1); + let full = DRect::from_size(doc); + if doc.width.div_ceil(k) == preview.width && doc.height.div_ceil(k) == preview.height { + return full; + } + let side = |p: u32, d: u32| { + let covered = i64::from(p) * i64::from(k); + let x0 = (i64::from(d) - covered).div_euclid(2); + let clamp = |v: i64| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + (clamp(x0), clamp(x0 + covered)) + }; + let (x0, x1) = side(preview.width, doc.width); + let (y0, y1) = side(preview.height, doc.height); + DRect::new(x0, y0, x1, y1) +} + +/// [`ensure_texture`], and for a canvas showing `view` of a document too large for one +/// full-resolution texture, the detail texture of the part in view ([`detail_plan`], #7568). +pub(crate) fn ensure_texture_in_view( + app: &mut PhotocraftApp, + ctx: &egui::Context, + idx: usize, + display: Option, + view: Option, +) -> Option { let output = display; let (revision, last_damage, id) = { let st = app.session.documents().get(idx)?; (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id) }; let (mut doc, mut preview_key) = display_doc(app, idx); + // A filter dialog's live preview: the GPU canvas draws it from its own texture, but this CPU + // canvas (no usable GPU, a flipped or pixel-aspect-corrected view, a GPU fallback) showed the + // document unfiltered. Its result is drawn here instead, as the channel views draw it. + // A preview reduced for a zoomed-out view (a proxy, factor `k`) covers the whole document at + // its own size. One whose dimensions change otherwise (Image › Rotation) covers its own area, + // centred on the document's, as the GPU canvas draws it (`preview_bounds`). + let committed = app.session.documents().get(idx).map(|st| st.doc.bounds()).unwrap_or(doc.bounds()); + let mut bounds = committed; + let mut reduced = false; + if let Some((preview, key)) = filter_channel_preview(app, idx, ctx) { + let k = app.filter_preview.as_ref().map_or(1, |p| p.key.k.max(1)); + bounds = preview_bounds(committed.size(), preview.size, k); + (doc, preview_key, reduced) = (preview, key, k > 1); + } + // Detail textures and partial redraws work in the document's own pixels. + let reduced = reduced || bounds != committed; + let view = view.filter(|_| !reduced); // A flipped view draws a GPU machine's canvas through here: an adjustment dialog's preview of // a large document (taken by the GPU path) would cost a full-size CPU composite per change. if crate::adjust_preview::shown_key(app) == Some(preview_key) @@ -1464,7 +1819,17 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, } let (display, display_key) = canvas_display(app, &doc, output); let seen = app.canvases.get(&cache_key(id, output)).map(|c| (c.tex_revision, c.tex_preview_key)); - let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage)); + let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage)).filter(|_| !reduced); + // The detail texture can lag the whole-document one (made or moved later): its own damage. + let detail_seen = view.and_then(|v| { + let c = app.canvases.get(&cache_key(id, output))?; + c.detail.iter().find(|(k, _)| *k == v.key).map(|(_, d)| (d.revision, d.preview_key)) + }); + let detail_damage = match detail_seen { + Some(s) if Some(s) == seen => damage, + Some(s) => damage_since(app, idx, s, (revision, preview_key), display_key, last_damage), + None => None, + }; let preview_key = preview_key ^ display_key; let cache = app.canvases.entry(cache_key(id, output)).or_insert(CanvasCache { revision: 0, @@ -1474,12 +1839,13 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, on_gpu: false, tex_revision: 0, tex_preview_key: 0, + texture_opaque: false, grid: None, + detail: Vec::new(), }); if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key { let t0 = crate::gpu_canvas::now_ms(); - let longest = doc.size.width.max(doc.size.height); - let factor = longest.div_ceil(texture_side(ctx)).max(1); + let factor = texture_factor([doc.size.width, doc.size.height], texture_side(ctx)); let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1)); let current = cache.texture.as_mut().filter(|t| t.size() == [w as usize, h as usize]); if let (Some(d), Some(t)) = (damage, current) { @@ -1489,7 +1855,11 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, let r = buf.rect; if !r.is_empty() { let t1 = crate::gpu_canvas::now_ms(); - t.set_partial([r.x0 as usize, r.y0 as usize], display_image(display.as_deref(), &buf), canvas_texture_options(factor)); + let image = display_image(display.as_deref(), &buf); + // Partial uploads can disprove opacity. Once transparent, conservatively keep + // the checker until a full upload proves the whole texture opaque again. + cache.texture_opaque &= image.pixels.iter().all(|p| p.is_opaque()); + t.set_partial([r.x0 as usize, r.y0 as usize], image, canvas_texture_options(factor)); let px = (r.width() as u64 * r.height() as u64).saturating_mul(u64::from(factor).pow(2)); app.perf.record("rect", px, t1 - t0, crate::gpu_canvas::now_ms() - t1); } @@ -1498,6 +1868,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, let full = photocraft_compose::render_reduced(&doc, w, h); let t1 = crate::gpu_canvas::now_ms(); let (img, scale) = (display_image(display.as_deref(), &full), 1.0 / factor as f32); + cache.texture_opaque = img.pixels.iter().all(|p| p.is_opaque()); match cache.texture.as_mut() { Some(t) if t.size() == img.size => t.set(img, canvas_texture_options(factor)), _ => cache.texture = Some(ctx.load_texture(format!("canvas-{}-{}", id.0, cache_key(id, output).1), img, canvas_texture_options(factor))), @@ -1508,7 +1879,108 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, cache.tex_revision = revision; cache.tex_preview_key = preview_key; } - Some((cache.texture.as_ref()?.id(), cache.scale)) + let texture = cache.texture.as_ref()?.id(); + let scale = cache.scale; + // Without a view (the Navigator's call) the views' detail textures stay as they are. + let Some(view) = view else { return Some(CpuTextures { texture, scale, bounds, detail: None }) }; + let old = cache.detail.iter().position(|(k, _)| *k == view.key).map(|i| cache.detail.remove(i).1); + let Some(plan) = detail_plan([doc.size.width, doc.size.height], texture_side(ctx), view.zoom, view.visible) else { + return Some(CpuTextures { texture, scale, bounds, detail: None }); + }; + let t0 = crate::gpu_canvas::now_ms(); + let name = format!("canvas-detail-{}-{}", id.0, cache_key(id, output).1); + let detail = update_detail(ctx, &doc, display.as_deref(), name, plan, old, detail_damage, (revision, preview_key)); + if detail.as_ref().is_some_and(|d| d.rendered) { + app.perf.span("canvas detail", crate::gpu_canvas::now_ms() - t0); + } + let Some(mut detail) = detail else { return Some(CpuTextures { texture, scale, bounds, detail: None }) }; + detail.rendered = false; + // Where its texels lie in the document: reduced texel t covers [t·W/w, (t+1)·W/w). + let (w, h) = ((doc.size.width / plan.factor).max(1), (doc.size.height / plan.factor).max(1)); + let (sx, sy) = (doc.size.width as f32 / w as f32, doc.size.height as f32 / h as f32); + let out = detail.out; + let shown = (detail.texture.id(), [out.x0 as f32 * sx, out.y0 as f32 * sy, out.x1 as f32 * sx, out.y1 as f32 * sy]); + cache.detail.push((view.key, detail)); + // Views that stopped asking (a closed window, a zoom out) give theirs up past a few. + if cache.detail.len() > MAX_DETAIL_VIEWS { + cache.detail.remove(0); + } + Some(CpuTextures { texture, scale, bounds, detail: Some(shown) }) +} + +/// Most CPU canvas views of one document on one display that keep a detail texture. +const MAX_DETAIL_VIEWS: usize = 4; + +/// The detail texture for `plan` at document state `now` (revision, preview key with the display +/// key folded in): `old` as it is when current, updated over `damage` when only that changed +/// within the same region, or rendered again. `None` when the region holds no texels. +#[allow(clippy::too_many_arguments)] +fn update_detail( + ctx: &egui::Context, + doc: &Document, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + name: String, + plan: DetailPlan, + old: Option, + damage: Option, + now: (u64, u64), +) -> Option { + let (w, h) = ((doc.size.width / plan.factor).max(1), (doc.size.height / plan.factor).max(1)); + let options = canvas_texture_options(plan.factor); + let (kept, spare) = match old { + Some(d) if d.plan == plan => (Some(d), None), + d => (None, d.map(|d| d.texture)), + }; + if let Some(mut d) = kept { + if (d.revision, d.preview_key) == now { + return Some(d); + } + // An edit or a stroke step in view: only the texels it touched. + if let Some(dmg) = damage { + let dmg = dmg.intersect(&plan.region); + let buf = (!dmg.is_empty()).then(|| photocraft_compose::render_reduced_damage(doc, w, h, dmg)); + let fits = buf.as_ref().is_none_or(|b| d.out.contains_rect(&b.rect)); + if fits { + if let Some(b) = buf.filter(|b| !b.rect.is_empty()) { + let at = [b.rect.x0.abs_diff(d.out.x0) as usize, b.rect.y0.abs_diff(d.out.y0) as usize]; + d.texture.set_partial(at, display_image(display, &b), options); + d.rendered = true; + } + (d.revision, d.preview_key) = now; + return Some(d); + } + } + return render_detail(ctx, doc, display, name, plan, Some(d.texture), now); + } + render_detail(ctx, doc, display, name, plan, spare, now) +} + +/// [`update_detail`]'s whole render of `plan`'s region, into `texture` when it is the same size. +fn render_detail( + ctx: &egui::Context, + doc: &Document, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + name: String, + plan: DetailPlan, + texture: Option, + now: (u64, u64), +) -> Option { + let (w, h) = ((doc.size.width / plan.factor).max(1), (doc.size.height / plan.factor).max(1)); + let options = canvas_texture_options(plan.factor); + let buf = photocraft_compose::render_reduced_damage(doc, w, h, plan.region); + let out = buf.rect; + if out.is_empty() { + return None; + } + let img = display_image(display, &buf); + let texture = match texture { + Some(mut t) if t.size() == img.size => { + t.set(img, options); + t + } + _ => ctx.load_texture(name, img, options), + }; + Some(DetailTexture { texture, plan, out, revision: now.0, preview_key: now.1, rendered: true }) } /// What changed since a canvas cache showed (`revision`, `preview key`) `seen`, when only a @@ -1570,6 +2042,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 Vanishing Point stamp previews (and the document): the areas the strokes painted. + if seen.0 == now.0 + && let Some(st) = app.session.documents().get(idx) + && let Some(r) = crate::vp_ui::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 blend modes (and the document): where the layer's blending shows. if seen.0 == now.0 && let Some(st) = app.session.documents().get(idx) @@ -1690,7 +2169,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: on_gpu: false, tex_revision: 0, tex_preview_key: 0, + texture_opaque: false, grid: None, + detail: Vec::new(), }); let present = cache.on_gpu && gpu.has(id.0, size); let current = cache.revision == revision && cache.preview_key == preview_key; @@ -2062,6 +2543,12 @@ fn visible_doc_rect(xf: &ViewXform) -> DRect { DRect::new(c(x0.floor()) - 2, c(y0.floor()) - 2, c(x1.ceil()) + 2, c(y1.ceil()) + 2) } +/// Prioritize deferred redraw blocks visible at either the target or the animated display camera. +/// The displayed camera may still show old texture contents outside the target's visible area. +fn redraw_focus(target: &ViewXform, displayed: &ViewXform) -> DRect { + visible_doc_rect(target).union(&visible_doc_rect(displayed)) +} + /// Zoomed-out Image › Adjustments preview on a large document: the wgpu compositor renders the /// reduced preview document (`adjust_preview::gpu_proxy`) into its own texture, over the target's /// area after the first frame. Returns (factor, gpu key, dimensions) to draw. @@ -2323,11 +2810,11 @@ 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.id().with(("dtab", i)), Sense::click_and_drag()); + let resp = ui.interact(r, ui.scope_id().with(("dtab", i)), 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::WidgetType::SelectableLabel, true, sel, label) + egui::WidgetInfo::selected(egui::Role::Button, true, sel, label) }); doc_tabs.push((i, r)); if sel { @@ -2345,7 +2832,7 @@ 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.id().with(("dtabx", i)), Sense::click()); + let xresp = ui.interact(xr, ui.scope_id().with(("dtabx", i)), Sense::click()); if xresp.hovered() { ui.painter().rect_filled(xr, 4.0, t.hover); } @@ -2372,7 +2859,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let sel = app.jobs.focus == Some(*job); let meta_g = ui.painter().layout_no_wrap(format!("{:.0}%", frac * 100.0), meta_font.clone(), t.text_faint); let name_g = crate::tab_strip::elided(ui, name, font.clone(), t.text, (r.width() - STUDIO_TAB_PAD + STUDIO_TAB_GAP - meta_g.size().x).max(1.0)); - let resp = ui.interact(r, ui.id().with(("dtabjob", job.0)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("dtabjob", job.0)), Sense::click()); 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); @@ -2384,7 +2871,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { 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); crate::jobs_ui::tab_underline(ui, r, *frac, &t); 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.id().with(("dtabjobx", job.0)), Sense::click()); + let xresp = ui.interact(xr, ui.scope_id().with(("dtabjobx", job.0)), Sense::click()); if xresp.hovered() { ui.painter().rect_filled(xr, 4.0, t.hover); } @@ -2396,7 +2883,8 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { } } if !f.overflow.is_empty() { - let left = x.min((row.right() - crate::tab_strip::CHEVRON_W).max(row.left())); + // 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(); @@ -2404,7 +2892,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { // 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.id().with("dtab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked); + 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) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open), @@ -2496,7 +2984,7 @@ fn tab_drag(ui: &egui::Ui, resp: &egui::Response, i: usize, r: Rect, strip: Rect { if torn_off(strip, p) { let ghost = Rect::from_min_size(p - egui::vec2(40.0, 12.0), egui::vec2(240.0, 160.0)); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.id().with("tab-tear-off"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.scope_id().with("tab-tear-off"))); painter.rect_filled(ghost, t.radius_sm, t.accent.gamma_multiply(0.12)); painter.rect_stroke(ghost, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } else { @@ -2667,7 +3155,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { for &(i, r) in placed.iter().filter(|(i, _)| *i < tab_count) { 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.id().with(("ptab", i)), Sense::click_and_drag()); + let resp = ui.interact(r, ui.scope_id().with(("ptab", i)), Sense::click_and_drag()); let sel = Some(i) == active; if sel { ui.painter().rect_filled(r, 0.0, t.chrome); @@ -2680,10 +3168,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { } 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.id().with(("ptabx", i)), Sense::click()); + let xresp = ui.interact(xr, ui.scope_id().with(("ptabx", i)), 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::WidgetType::SelectableLabel, true, sel, g.text())); - xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Close"))); + 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"))); crate::icons::paint(ui, xr, "x", 10.0, if xresp.hovered() { t.text } else { t.text_faint }); ui.painter().galley_with_override_text_color(at, g, if sel { t.text } else { t.text_faint }); let cut = natural.get(i).copied().unwrap_or(0.0) > r.width() + 0.5; @@ -2708,7 +3196,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let Some((job, _, frac)) = opening.get(i - tab_count) 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.id().with(("ptabjob", job.0)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("ptabjob", job.0)), Sense::click()); let sel = app.jobs.focus == Some(*job); if sel { ui.painter().rect_filled(r, 0.0, t.chrome); @@ -2718,8 +3206,8 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.separator)); crate::jobs_ui::tab_underline(ui, r, *frac, &t); let (xr, at) = pro_tab_layout(r, g.size().y, mac); - let xresp = ui.interact(xr, ui.id().with(("ptabjobx", job.0)), Sense::click()); - xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Close"))); + let xresp = ui.interact(xr, ui.scope_id().with(("ptabjobx", job.0)), Sense::click()); + xresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Close"))); crate::icons::paint(ui, xr, "x", 10.0, if xresp.hovered() { t.text } else { t.text_faint }); ui.painter().galley_with_override_text_color(at, g, if sel { t.text } else { t.text_faint }); if xresp.on_hover_text(tl!("Cancel opening")).clicked() { @@ -2729,14 +3217,15 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { } } if !f.overflow.is_empty() { - let left = x.min((strip.right() - crate::tab_strip::CHEVRON_W).max(strip.left())); + // Pinned to the strip's right end, as Photoshop's is, not after the last tab (#8290). + let left = (strip.right() - crate::tab_strip::CHEVRON_W).max(strip.left()); let r = Rect::from_min_size(egui::pos2(left, strip.top()), egui::vec2(crate::tab_strip::CHEVRON_W, strip.height())); let labels: Vec<&str> = titles.iter().map(String::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.id().with("ptab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked); + 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) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open), @@ -2819,7 +3308,7 @@ fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option(key)) { return Some(tex.id()); } @@ -2869,7 +3358,7 @@ pub fn mode_label(doc: &Document) -> &'static str { /// (#5367), and so does the Home screen's blank background. Registered before anything drawn over /// `area`, so the buttons and links there keep their own clicks; not a keyboard focus stop. fn open_on_double_click(app: &mut PhotocraftApp, ui: &egui::Ui, area: Rect) { - if app.session.documents().is_empty() && ui.interact(area, ui.id().with("empty-workspace"), Sense::CLICK).double_clicked() { + if app.session.documents().is_empty() && ui.interact(area, ui.scope_id().with("empty-workspace"), Sense::CLICK).double_clicked() { let _ = app.open_dialog_file(); } } @@ -3008,12 +3497,12 @@ fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) { let fg = crate::tab_strip::elided(ui, &folder, egui::FontId::proportional(11.5), t.text_faint, folder_max); ui.painter().galley(egui::pos2(right - fg.size().x, row.center().y - fg.size().y / 2.0), fg, t.text_faint); } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); let resp = resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand); if hovered { let x = ui.interact(close, resp.id.with("remove"), Sense::click()); let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &name)]); - x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); let color = crate::icons::button_chrome(ui, close, false, x.hovered()); crate::icons::paint(ui, close, "x", 12.0, color); if x.on_hover_text(tl!("Remove from Recent")).clicked() { @@ -3124,6 +3613,33 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Every document view has its rulers, the tiles of Window › Arrange and floating windows // too, as in Photoshop (#6017). let rect = crate::rulers::content_rect(app, rect); + // Both wheel reads share one owner so snap-zoom input is accumulated only once per frame. + let wheel_owner = ui.scope_id().with(("canvas-wheel", idx)); + // Direct navigation starts from the authoritative camera. This also means a click or drag + // never maps through a camera that is still moving under the pointer. + let (pointer_here, hover_here) = ctx.input(|i| { + ( + (i.pointer.any_pressed() || i.pointer.any_down()) && i.pointer.press_origin().is_some_and(|p| rect.contains(p)), + i.pointer.hover_pos().is_some_and(|p| rect.contains(p)), + ) + }); + let direct_input = pointer_here + || (hover_here + && crate::wheel_nav::read_with( + &ctx, + app.session.prefs().general.zoom_with_scroll_wheel, + app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, + app.session.prefs().general.animated_zoom, + wheel_owner, + hover_here, + app.session.prefs().interface.enable_animations, + app.session.prefs().enhanced_controls.touch_gestures, + ) + .is_some()); + if direct_input { + view.zoom_animating = false; + view.zoom_anchor = None; + } let doc = app.session.documents()[idx].doc.clone(); let size = [doc.size.width, doc.size.height]; if view.doc_size != size { @@ -3143,12 +3659,22 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs). if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) { + view.zoom_anchor = None; ctx.request_repaint(); } let flip = app.ui.view.flip_horizontal; - // Canvas geometry (ViewXform, ViewParams, painter rects) is in egui points. - let point_zoom = view.zoom / ppp; - let xf = ViewXform { rect, zoom: point_zoom, center: view.center, flip, rotation: view.rotation, aspect }; + // 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 }; + let render_zoom = view.zoom; + let mut display_view = animated_view(&mut view, target_xf, ppp, app.session.prefs().general.animated_zoom, ctx.input(|i| i.time)); + if primary && let Some(stored) = app.ui.views.get_mut(idx) { + stored.zoom_animating = view.zoom_animating; + stored.display_camera = view.display_camera; + } + if view.zoom_animating { + ctx.request_repaint(); + } + let xf = ViewXform { zoom: display_view.zoom / ppp, center: display_view.center, ..target_xf }.pixel_aligned_for(app, ppp); let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid); let response = ui.allocate_rect(rect, Sense::click_and_drag()); let stroke_dismissal = primary && crate::shape_stroke_ui::dismiss_from_canvas(app, &response); @@ -3169,6 +3695,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let drop_shadow = border == photocraft_engine::prefs::CanvasBorder::DropShadow; // Drop shadow, checkerboard, document image. let img_rect = xf.doc_rect(doc.bounds()); + // The canvas edge the Line border outlines: a size-changing preview's own (CPU canvas). + let mut edge_rect = img_rect; + // The document area the image is drawn over: a size-changing preview's own on the CPU + // canvas (Image › Rotation). Overlays drawn from the same preview follow it. + let mut shown_area = doc.bounds(); // Crop tool: the layers' pixels past the canvas while a frame is edited, under the canvas // (whose edge blends into them) and without its shadow. let beyond = draw_beyond_canvas(app, &painter, &xf, idx, output); @@ -3188,8 +3719,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation, aspect); if app.gpu.is_some() && gpu_ok - && let Some((k, key, preview_size)) = - ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx, view.zoom)) + && let Some((k, key, preview_size)) = ensure_adjust_proxy(app, idx, render_zoom) + .or_else(|| ensure_filter_preview(app, idx, &ctx)) + .or_else(|| ensure_proxy_preview(app, idx, render_zoom)) { on_gpu = true; app.navigator_gpu.remove(&doc.id); @@ -3201,8 +3733,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: zoom: view.zoom * k as f32 / ppp, // A size-changing preview grows around the old image center; keep the view's pan. center: [ - view.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0, - view.center[1] / k as f32 + (preview_size[1] as f32 - original_size[1] as f32) / 2.0, + xf.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0, + xf.center[1] / k as f32 + (preview_size[1] as f32 - original_size[1] as f32) / 2.0, ], rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 }, shadow: { @@ -3210,14 +3742,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: !t.bevel && !t.pro && drop_shadow }, pixel_grid: false, - view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(), + view_key: egui::Id::unique(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(), display: sync_display_lut(app, &shown, 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, visible_doc_rect(&xf)) { + } else if gpu_ok && ensure_gpu(app, &ctx, idx, redraw_focus(&target_xf, &xf)) { on_gpu = true; app.perf.gpu = true; // Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs). @@ -3225,14 +3757,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: doc: doc.id.0, doc_size: [doc.size.width, doc.size.height], zoom: point_zoom, - center: view.center, + 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::new(("pc-canvas", ctx.viewport_id(), idx)).value(), + view_key: egui::Id::unique(("pc-canvas", ctx.viewport_id(), idx)).value(), display: sync_display_lut(app, &doc, doc.id.0, output), output: output.unwrap_or(0), hdr: hdr_preview(app, &doc), @@ -3246,31 +3778,63 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: paint_gpu_view(&painter, rect, params, &pattern); } else { app.navigator_gpu.remove(&doc.id); + // A document too large for one full-resolution texture also gets the part in view at + // the screen's resolution, drawn over the reduced whole (#7568). + let in_view = InView { + visible: visible_doc_rect(&xf), + zoom: view.zoom, + key: egui::Id::unique(("pc-canvas-detail", ctx.viewport_id(), idx, primary)).value(), + }; + let textures = ensure_texture_in_view(app, &ctx, idx, output, Some(in_view)); + let tex = textures.as_ref().map(|t| t.texture); + // The area the image covers: a size-changing preview's own (Image › Rotation). + let area = textures.as_ref().map_or(doc.bounds(), |t| t.bounds); + shown_area = area; + if area != doc.bounds() { + edge_rect = xf.doc_rect(area); + } if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow { - painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0)); + let shadow_rect = edge_rect; + painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(shadow_rect, 0)); } let rotated = view.rotation.abs() > 0.05; let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square)); - let tex = ensure_texture(app, &ctx, idx, output).map(|(tex, _scale)| tex); - paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex); - for &c in &pattern { - paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex); + // Repeated-quad coverage is not proven identical on all Pattern Preview adapters. + // Retain its legacy checker submissions, including the central document's. + let opaque = !app.ui.view.pattern_preview && app.canvases.get(&cache_key(doc.id, output)).is_some_and(|c| c.texture_opaque) && tex.is_some(); + paint_cpu_document(&painter, &xf, area, rotated, checkerboard, tex, opaque); + if let Some((detail, area)) = textures.and_then(|t| t.detail) { + paint_cpu_detail(&painter, &xf, doc.bounds(), rotated, checkerboard, detail, area); + } + // Pattern Preview tiles what is shown: a size-changing preview's area, from its origin. + let (tile, copies) = if area == doc.bounds() || !app.ui.view.pattern_preview { + (size, pattern.clone()) + } else { + let tile = [area.width(), area.height()]; + let v = visible_doc_rect(&xf).translate(-area.x0, -area.y0); + (tile, crate::pattern_preview::copies([v.x0, v.y0, v.x1, v.y1].map(f64::from), tile)) + }; + for &c in &copies { + paint_cpu_document(&painter, &pattern_xf(&xf, c, tile), area, rotated, checkerboard, tex, opaque); } } // Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs). if let Some(tex) = crate::channel_view::ensure(app, &ctx, idx) { + // Rendered from the live preview, so over the area the preview is shown in. + let a = shown_area; if view.rotation.abs() > 0.05 { - paint_mapped_image( + paint_mapped_image_at( &painter, &xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), - [doc.size.width as f32, doc.size.height as f32], + [a.x0 as f32, a.y0 as f32], + [a.width() as f32, a.height() as f32], Color32::WHITE, ); } else { let uv = if flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) }; - painter.image(tex, img_rect, uv, Color32::WHITE); + painter.image(tex, xf.doc_rect(a), uv, Color32::WHITE); } } crate::color_range_ui::paint_selection_preview(app, &ctx, &painter, doc.id, img_rect, flip); @@ -3332,8 +3896,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // 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, idx); + // A floating piece's ants hug the pixels being moved. + let floated = floating_outline(app, idx); let (outlined, k) = match &refined { - None => (doc.selection.as_ref(), 1), + None => (floated.as_ref().or(doc.selection.as_ref()), 1), Some(None) => (None, 1), Some(Some((proxy, k))) => (proxy.selection.as_ref(), (*k).max(1)), }; @@ -3417,9 +3983,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } - (img_rect, beyond, layer_edges) + (beyond, layer_edges, edge_rect) }; - let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &view, xf)) }; + let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &display_view, xf)) }; let img_rect = xf.doc_rect(doc.bounds()); // Under an open dialog the canvas widget is inert, but the image still pans and zooms. @@ -3431,10 +3997,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } // Navigation (wheel_nav.rs): scroll pans (⇧ or ⌘/Ctrl sideways); pinch and ⌥-scroll zoom around the pointer. // The wheel also scrolls over the scrollbars drawn on top of the canvas (last frame's hover). - let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx)); + let bars_id = egui::Id::unique(("pc-canvas-bars-hover", idx)); let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); let nav_hovered = response.hovered() || free_hover || over_bars; // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). + // General › Animated Zoom: the wheel zoom eases over the frames of a notch, or is one instant step. // Enhanced Controls › Enable Gestures off: two fingers on a touch screen neither zoom nor // turn the view (#204); the mouse and trackpad are unaffected. let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; @@ -3442,7 +4009,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: &ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, - response.id.with("wheel"), + app.session.prefs().general.animated_zoom, + wheel_owner, nav_hovered, app.session.prefs().interface.enable_animations, touch_gestures, @@ -3454,6 +4022,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // At a limit the view stays put, as in Photoshop: a notch past 12800 % neither // zooms nor slides the image towards the pointer. let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size); + if nz != view.zoom * f { + crate::wheel_nav::settle(&ctx, wheel_owner); + } if nz != view.zoom { // Over the pasteboard the anchor is the image's nearest point (#3720). let at = wheel_anchor(&xf, p, view.doc_size); @@ -3479,6 +4050,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let rotate_trackpad = app.session.prefs().enhanced_controls.rotate_view_with_trackpad; if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad, touch_gestures) { + view.zoom_animating = false; + view.zoom_anchor = None; let next = view.rotation + crate::rotate_view::screen_delta(delta, xf.flip); if next.is_finite() { view.rotation = crate::rotate_view::wrap_deg(next); @@ -3565,7 +4138,27 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: .map(|m| crate::workspace_ui::sticky_mods(app, m)) .filter(|m| m.command); let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag && curve_point_press.is_none(); - if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { + // ⌘Space / ⌥Space zoom under the dialog as they do without one, before Space pans (#6460). + // With the Hand tool, ⌘ alone is Zoom In (#6434); under Curves that press is the dialog's + // point (#7325), not a zoom. + let curve_point_with_hand = curve_point_press.is_some() && app.ui.tool == Tool::Hand; + let held_zoom = + !middle && !curve_point_with_hand && matches!(temporary, Some(crate::hold_keys::Temporary::ZoomIn | crate::hold_keys::Temporary::ZoomOut)); + if held_zoom { + let alt = ctx.input(|i| i.modifiers.alt); + if free_hover { + ctx.set_cursor_icon(crate::tool_cursor::zoom(&ctx, zoom_out(alt))); + } + let b = crate::dialogs::free_buttons(&ctx, rect); + let pointer = ctx.input(|i| i.pointer.latest_pos()); + crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &b, pointer); + if b.clicked + && let Some(p) = pointer + { + let nz = crate::zoom_levels::step(view.zoom, if zoom_out(alt) { -1 } else { 1 }, view.doc_size); + zoom_about(&mut view, &xf, p, nz, app.session.prefs().tools.zoom_clicked_point_to_center, ppp); + } + } else if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; view.center[1] -= d.y; @@ -3632,11 +4225,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle; let hand_pan = tool == Tool::Hand && !transform_owns_pointer; // Preferences › Tools › Enable Flick Panning: a Hand drag let go while moving glides on. - let flick_id = egui::Id::new(("pc-flick-pan", idx, primary, ctx.viewport_id())); + let flick_id = egui::Id::unique(("pc-flick-pan", idx, primary, ctx.viewport_id())); let flick_on = app.session.prefs().tools.enable_flick_panning; if let Some(d) = crate::flick_pan::frame(&ctx, flick_id, hand_pan && response.dragged() && !stroke_dismissal, response.drag_delta(), flick_on) { let d = xf.unmap_vec(d) / (view.zoom / ppp); if d.is_finite() { + view.zoom_animating = false; + view.zoom_anchor = None; view.center[0] -= d.x; view.center[1] -= d.y; } @@ -3673,7 +4268,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } else if primary && !stroke_dismissal { let mods = ui.input(|i| i.modifiers); // An ⌥-click the window manager took never arrives; say so (alt_grab.rs). - crate::alt_grab::watch(app, &ctx, egui::Id::new(("pc-alt-grab", idx)), rect, tool, !under_dialog); + crate::alt_grab::watch(app, &ctx, egui::Id::unique(("pc-alt-grab", idx)), rect, tool, !under_dialog); // Tools follow the left button; the right one opens the Brush Preset picker or erases // (Preferences › Tools, `paint_mouse`). crate::paint_mouse::sync_tool_brush(app); @@ -3683,10 +4278,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if tool == Tool::Crop && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()) && crate::crop_ui::press(app) { ctx.request_repaint(); } - // Capture intent at the press, before egui's drag threshold: Type transforms keep - // Command, and marquee add/subtract must not become a live Shift/Alt constraint. - let capture_press = tool.is_type() || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee); - let tool_press = egui::Id::new(("tool-pointer-press-modifiers", idx)); + // Capture intent at the press, before egui's drag threshold or click replay on release: + // Type transforms keep Command; selections keep Add/Subtract if Shift/Alt is released. + let capture_press = tool.is_type() + || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee | Tool::ObjectSelection); + let tool_press = egui::Id::unique(("tool-pointer-press-modifiers", idx)); if capture_press && let Some((p, press_mods)) = ui.input(|i| { i.events.iter().find_map(|e| match e { @@ -3754,9 +4350,18 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: && !crate::layer_pick_ui::is_gesture(tool, mods) && let Some(p) = response.interact_pointer_pos() { + // A pending crop: its menu, also outside the image, where the frame can reach (#6561). // Outside the image: the pasteboard colour menu (#2462), else the tool's own. The view - // menu belongs to the selected Zoom or Hand, not to Space or a held zoom key. - if crate::canvas_tool_menu::pasteboard_at(app, tool, xf.to_doc(p)) { + // menu belongs to the selected Zoom or Hand, not to Space or a held zoom key. A crop + // frame or Straighten line still dragged by the left button gets no menu over it: the + // gesture and the held button both, as either alone can outlive a lost release. A + // finger held still (egui's press-and-hold right-click) is down too, but drags nothing. + let crop_gesture = tool == Tool::Crop + && !response.long_touched() + && (app.crop.drag.is_some() || app.crop.straighten.line.is_some()) + && ctx.input(|i| i.pointer.primary_down()); + if crop_gesture || crate::canvas_tool_menu::open_crop(app, &ctx, tool, [p.x, p.y]) { + } else if crate::canvas_tool_menu::pasteboard_at(app, tool, xf.to_doc(p)) { crate::canvas_tool_menu::open_pasteboard(app, &ctx, tool, [p.x, p.y]); } else if crate::canvas_tool_menu::is_view_tool(tool) { if tool == app.ui.tool { @@ -3818,7 +4423,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.press_stroke = app.drag.is_some(); } let press_stroke = app.press_stroke; - if press_stroke { + // A live tool's press that went to the ⌥ eyedropper keeps sampling for the whole press: the + // drag recognised later must not decide again with ⌥ already let go and paint (#7802). + if press_stroke || (strokes_live(tool) && (app.alt_sampling || app.mixer_sampling)) { buttons.started = false; } if buttons.started @@ -3900,7 +4507,17 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if press_stroke { buttons.clicked = false; } - let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]]))); + let at = response.interact_pointer_pos(); + // #6286: a guide let go over a ruler is removed, whichever ruler it is. Tested here in + // screen space, where the rulers are: on the guide's own axis the release can still be + // inside the document, which is what `finish_drag` sees. + if app.guide_drag.is_some() { + let on_ruler = at.is_some_and(|p| crate::rulers::dropped_on_a_ruler(app, full, p)); + if let Some(g) = app.guide_drag.as_mut() { + g.over_ruler = on_ruler; + } + } + let p = at.map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]]))); if let Some(d) = p { tap_at_peak_pressure(app); tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods); @@ -3926,12 +4543,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary }; let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods); + let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary { + ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods) + } else { + click_mods + }; let d = xf.to_doc(p); match tool { Tool::Zoom if !transform_owns_pointer => { let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size); let center = app.session.prefs().tools.zoom_clicked_point_to_center; + let doc_anchor = xf.to_doc(p); + let screen_anchor = p; zoom_about(&mut view, &xf, p, nz, center, ppp); + view.request_zoom_animation(app.session.prefs().general.animated_zoom && nz != display_view.zoom); + view.zoom_anchor = (view.zoom_animating && !center).then_some((doc_anchor, [screen_anchor.x, screen_anchor.y])); } // A click with the (temporary) Hand does nothing, never the tool underneath. Tool::Hand | Tool::RotateView => {} @@ -4024,8 +4650,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.request_repaint(); } } - let xf = if crop_camera { ViewXform { center: view.center, rotation: view.rotation, ..xf } } else { xf }; - let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &view, xf)); + let xf = if crop_camera { + if view.center != target_xf.center { + display_view.center = view.center; + } + display_view.rotation = view.rotation; + ViewXform { center: display_view.center, rotation: display_view.rotation, ..xf }.pixel_aligned_for(app, ppp) + } else { + xf + }; + let (beyond, layer_edges, edge_rect) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, xf)); // 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. @@ -4034,6 +4668,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // The transform preview is image content: it goes under the grid and guides, which // stay visible across a layer being transformed (#5086); the box is drawn later. crate::transform_tool::draw_content(app, &painter, &xf); + // So are Puppet Warp's, Perspective Warp's and Vanishing Point's warped pixels (#7781). + crate::distort_ui::draw_content(app, &painter, &xf); if app.ui.extras.grid && app.ui.view.extras { crate::rulers::draw_grid(app, &painter, &xf, &doc); } @@ -4070,7 +4706,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // The canvas edge isn't outlined while the pixels past it show (Photoshop's crop preview), // nor while the Crop tool hides everything outside its box (Show Cropped Area off). if border == photocraft_engine::prefs::CanvasBorder::Line && !beyond && !crate::crop_shield::hides_outside(app) { - painter.rect_stroke(img_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside); + painter.rect_stroke(edge_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside); } crate::type_tool::draw_overlay(app, &painter, &xf); crate::transform_tool::draw_overlay(app, &painter, &xf); @@ -4096,6 +4732,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }); if panning { // The fist set with the pan above stays (#2196). + } else if crate::tool_cursor::pointer_elsewhere(&ctx, &crate::panel_docking::native_viewports(app, &ctx)) { + // The pointer is over a floating panel's own window; this window's last position of + // it is stale, so no tool cursor or brush outline is painted at it. } else if let Some((vertical, _)) = guide_hover { ui.ctx().set_cursor_icon(crate::rulers::guide_cursor(&xf, vertical)); } else if let Some(p) = response.hover_pos() @@ -4173,6 +4812,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if !resizing && !alt && !app.ui.shell.sticky_alt { draw_clone_preview(app, &painter, &xf, xf.to_doc(p), output); } + // The Type tool over a path shows Photoshop's area-type or type-on-a-path pointer (#218). + // Not while a type edit is open or a text box is dragged out, nor over type a click + // would edit (`hover_path_target`). + let type_path = if matches!(tool, Tool::Type | Tool::VerticalType) { + let [x, y] = xf.to_doc(p); + let shift = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)).shift; + crate::type_tool::hover_path_target(app, x, y, shift) + } else { + None + }; let icon = match tool { // Resizing the brush: the circle stays where the drag began (`brush_resize`). t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None, @@ -4238,7 +4887,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // tip keeps the circle. _ => { let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip); - let axes = crate::brush_cursor::tip_axes(&xf, brush.size); + let axes = crate::brush_cursor::pen_axes(&xf, brush, app.stylus.sample()); crate::brush_cursor::cursor(&painter, p, brush, full, axes, cur.painting == PaintingCursor::NormalTip, centre) } } @@ -4302,6 +4951,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false), Tool::RotateView => crate::rotate_view::cursor(response.dragged()), Tool::Zoom => crate::tool_cursor::zoom(ui.ctx(), zoom_out(alt)), + Tool::Type | Tool::VerticalType if type_path.is_some() => match type_path { + Some(crate::type_tool::PathClick::Area { .. }) => crate::tool_cursor::area_type(&painter, p), + _ => crate::tool_cursor::path_type(&painter, p), + }, Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => egui::CursorIcon::Text, Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom), Tool::RedEye => { @@ -4352,7 +5005,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let t0 = crate::gpu_canvas::now_ms(); let before = view.center; let over_bars = app.ui.view.shows_scrollbars() - && crate::scrollbars::show(ui, rect, &mut view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::new(("pc-canvas", idx))); + && crate::scrollbars::show(ui, rect, &mut view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::unique(("pc-canvas", idx))); ctx.data_mut(|d| d.insert_temp(bars_id, over_bars)); if view.center != before { ctx.request_repaint(); @@ -4362,10 +5015,20 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.hover_doc = response.hover_pos().map(|p| xf.to_doc(p)); } if primary && app.ui.extras.rulers { + let before = app.guide_drag; crate::rulers::draw_rulers(app, ui, full, &xf); + // A guide dragged out of a ruler moved after this frame drew it: draw it again where it is. + if app.guide_drag != before { + ctx.request_repaint(); + } } else if app.ui.extras.rulers { crate::rulers::paint_rulers(app, ui, full, &xf, &doc); } + // After the ruler drags above, so the readout shows where the guide is now, and not after a + // release ended the drag. + if app.session.active_index() == Some(idx) { + crate::rulers::draw_guide_drag_readout(app, &ctx); + } crate::rotate_view::overlay(app, &ctx, &painter, &xf, &mut view, &response); if primary { // Tool input can change the stored camera while navigation edits the local view. @@ -4385,9 +5048,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: view } -/// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under -/// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the -/// centre of the view instead (#204). /// Where a wheel or pinch zoom anchors for a pointer at `p`: the pointer itself over the image, /// otherwise the nearest point of the image (#3720). Anchoring on a pointer out on the gray /// pasteboard slid the image away from it, out of the window after a few notches; Photoshop keeps @@ -4402,6 +5062,8 @@ fn wheel_anchor(xf: &ViewXform, p: Pos2, doc_size: [u32; 2]) -> Pos2 { if c == d { p } else { xf.to_screen(c[0] as f32, c[1] as f32) } } +/// Zoom to `new_zoom` about `p`: keep its document point under the pointer, or move it to the +/// view centre when Zoom Clicked Point to Center (`center_on_point`) is enabled. fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) { let before = xf.to_doc(p); view.zoom = new_zoom; @@ -4415,10 +5077,10 @@ fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on view.center = [before[0] as f32 - u.x, before[1] as f32 - u.y]; } -/// Textured document quad whose corners follow [`ViewXform`] (rotation + flip). -fn paint_mapped_image(painter: &egui::Painter, xf: &ViewXform, tex: egui::TextureId, uv: Rect, size: [f32; 2], tint: Color32) { - let [w, h] = size; - let pts = [xf.to_screen(0.0, 0.0), xf.to_screen(w, 0.0), xf.to_screen(w, h), xf.to_screen(0.0, h)]; +/// A texture mapped over the `size` document area starting at `origin` (follows a rotated view). +fn paint_mapped_image_at(painter: &egui::Painter, xf: &ViewXform, tex: egui::TextureId, uv: Rect, origin: [f32; 2], size: [f32; 2], tint: Color32) { + let ([x, y], [w, h]) = (origin, size); + let pts = [xf.to_screen(x, y), xf.to_screen(x + w, y), xf.to_screen(x + w, y + h), xf.to_screen(x, y + h)]; let uvs = [uv.min, pos2(uv.max.x, uv.min.y), uv.max, pos2(uv.min.x, uv.max.y)]; let mut mesh = Mesh::with_texture(tex); for i in 0..4 { @@ -4438,7 +5100,7 @@ fn paint_gpu_view(painter: &egui::Painter, rect: Rect, params: crate::gpu_canvas offset: crate::pattern_preview::offset(c, params.doc_size), shadow: false, // Keyed by slot, not by tile index, so panning reuses the same few uniform buffers. - view_key: egui::Id::new((params.view_key, "pattern", k)).value(), + view_key: egui::Id::unique((params.view_key, "pattern", k)).value(), ..params }; crate::gpu_canvas::GpuCanvas::paint(painter, rect, copy); @@ -4461,22 +5123,27 @@ fn paint_cpu_document( rotated: bool, checker: Option<(egui::TextureId, f32)>, tex: Option, + opaque: bool, ) { let size = [bounds.width() as f32, bounds.height() as f32]; + let origin = [bounds.x0 as f32, bounds.y0 as f32]; let img_rect = xf.doc_rect(bounds); match checker { - Some((checker_id, square)) => { + Some((checker_id, square)) if !opaque || tex.is_none() => { if rotated { let tiles = vec2(size[0], size[1]) / (2.0 * square); - paint_mapped_image(painter, xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), size, Color32::WHITE); + paint_mapped_image_at(painter, xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), origin, size, Color32::WHITE); } else { let tiles = img_rect.size() / (2.0 * square); painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE); } } + // On software window adapters this hidden full-canvas pass is CPU rasterization too. + // Keep the white-background branch unchanged (its antialiased outer edge is visible). + Some(_) => {} None => { if rotated { - paint_mapped_quad(painter, xf, size, Color32::WHITE); + paint_mapped_quad(painter, xf, origin, size, Color32::WHITE); } else { painter.rect_filled(img_rect, 0.0, Color32::WHITE); } @@ -4484,7 +5151,7 @@ fn paint_cpu_document( } if let Some(tex) = tex { if rotated { - paint_mapped_image(painter, xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), size, Color32::WHITE); + paint_mapped_image_at(painter, xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), origin, size, Color32::WHITE); } else { let uv = if xf.flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) }; painter.image(tex, img_rect, uv, Color32::WHITE); @@ -4492,9 +5159,51 @@ fn paint_cpu_document( } } -fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, size: [f32; 2], color: Color32) { - let [w, h] = size; - let pts = vec![xf.to_screen(0.0, 0.0), xf.to_screen(w, 0.0), xf.to_screen(w, h), xf.to_screen(0.0, h)]; +/// [`ensure_texture_in_view`]'s detail texture over document area `area` (x0, y0, x1, y1), on its +/// own checkerboard (or white), so the reduced texture under it doesn't show through where the +/// image is transparent. The checkerboard keeps [`paint_cpu_document`]'s phase. +fn paint_cpu_detail( + painter: &egui::Painter, + xf: &ViewXform, + bounds: DRect, + rotated: bool, + checker: Option<(egui::TextureId, f32)>, + tex: egui::TextureId, + area: [f32; 4], +) { + let [x0, y0, x1, y1] = area; + let corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + let pts = corners.map(|[x, y]| xf.to_screen(x, y)); + let quad = |tex: egui::TextureId, uvs: [Pos2; 4]| { + let mut mesh = Mesh::with_texture(tex); + for (pos, uv) in pts.into_iter().zip(uvs) { + mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); + }; + match checker { + Some((checker_id, square)) => { + let tile = 2.0 * square; + let uvs = if rotated { + corners.map(|[x, y]| pos2(x / tile, y / tile)) + } else { + let min = xf.doc_rect(bounds).min; + pts.map(|p| ((p - min) / tile).to_pos2()) + }; + quad(checker_id, uvs); + } + None => { + painter.add(egui::Shape::convex_polygon(pts.to_vec(), Color32::WHITE, Stroke::NONE)); + } + } + quad(tex, [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]); +} + +fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, origin: [f32; 2], size: [f32; 2], color: Color32) { + let ([x, y], [w, h]) = (origin, size); + let pts = vec![xf.to_screen(x, y), xf.to_screen(x + w, y), xf.to_screen(x + w, y + h), xf.to_screen(x, y + h)]; painter.add(egui::Shape::convex_polygon(pts, color, Stroke::NONE)); } @@ -4510,7 +5219,7 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli } /// The part of the line `a`–`b` inside `r` (Liang–Barsky), if any. -fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { +pub(crate) fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { // Perspective can put endpoints billions of pixels away. f32 subtraction/interpolation // loses viewport-sized differences there, clipping visible ants to the wrong endpoints. let (ax, ay) = (f64::from(a.x), f64::from(a.y)); @@ -4550,94 +5259,10 @@ mod transform_outline_clip_tests { } } -/// Marching ants along lines already placed on screen (a warped outline is a run of short ones). +/// Marching ants along lines already placed on screen (a warped outline is a run of short ones), +/// in device pixels like Photoshop's (`crate::ants`, #7489). pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator, time: f64) { - // Longer than any line that fits the view: only its part in view is dashed. - const LONG: f32 = 8192.0; - let dash = 4.0f32; - let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32; - let clip = painter.clip_rect(); - // Horizontal and vertical segments go in one mesh of 1 px quads, which egui draws as it is: a - // line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen - // whole (about 10⁵ segments), ten frames a second while the selection is shown. A slanted - // segment (a transformed outline) stays a line shape, which egui antialiases. - let mut mesh = egui::Mesh::default(); - // A 1 px wide quad from `a` to `b` (`side` is half a pixel across the line). - let mut quad = |a: egui::Pos2, b: egui::Pos2, side: egui::Vec2, color: Color32| { - let i = mesh.vertices.len() as u32; - for p in [a + side, a - side, b - side, b + side] { - mesh.colored_vertex(p, color); - } - mesh.add_triangle(i, i + 1, i + 2); - mesh.add_triangle(i, i + 2, i + 3); - }; - for (pa, pb) in lines { - // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon). - if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) { - continue; - } - let (pa, pb) = if pa.distance(pb) > LONG { - let Some(inside) = clip_line(clip.expand(4.0 * dash), pa, pb) else { continue }; - inside - } else { - (pa, pb) - }; - let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5); - let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5); - let len = pa.distance(pb); - if len < 0.5 { - continue; - } - let dir = (pb - pa) / len; - let side = dir.rot90() * 0.5; - let straight = pa.x == pb.x || pa.y == pb.y; - if straight { - // The white base runs half a pixel past both ends, so joined segments meet at corners. - quad(pa - dir * 0.5, pb + dir * 0.5, side, Color32::WHITE); - } else { - painter.line_segment([pa, pb], Stroke::new(1.0, Color32::WHITE)); - } - // Phase by screen position so dashes line up across joined segments. - let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0); - let mut t = -start; - while t < len { - let s0 = t.max(0.0); - let s1 = (t + dash).min(len); - if s1 > s0 { - if straight { - quad(pa + dir * s0, pa + dir * s1, side, Color32::BLACK); - } else { - painter.line_segment([pa + dir * s0, pa + dir * s1], Stroke::new(1.0, Color32::BLACK)); - } - } - t += dash * 2.0; - } - } - if !mesh.is_empty() { - painter.add(egui::Shape::mesh(mesh)); - } -} - -#[allow(dead_code)] -fn marching_ants(painter: &egui::Painter, r: Rect, time: f64) { - let dash = 4.0; - let offset = ((time * 8.0) % (dash as f64 * 2.0)) as f32; - painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle); - let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; - for w in pts.windows(2) { - let (a, b) = (w[0], w[1]); - let len = a.distance(b); - let dir = (b - a) / len.max(1e-3); - let mut t = -offset; - while t < len { - let s = t.max(0.0); - let e = (t + dash).min(len); - if e > s { - painter.line_segment([a + dir * s, a + dir * e], Stroke::new(1.0, Color32::BLACK)); - } - t += dash * 2.0; - } - } + crate::ants::draw(painter, lines, time, true); } /// Photoshop crop overlay: the `shield` outside (`crop_shield::fill`; none when disabled), bright @@ -4779,7 +5404,7 @@ fn ring(outer: DRect, inner: DRect) -> Vec { fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, output: Option) -> bool { let ctx = painter.ctx().clone(); let Some((snapshot, id)) = app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id)) else { return false }; - let key = egui::Id::new(("crop-beyond-canvas", id.0, output)); + let key = egui::Id::unique(("crop-beyond-canvas", id.0, output)); let frame = app.ui.crop_rect.filter(|f| f.iter().all(|v| v.is_finite()) && app.session.active_index() == Some(idx) && crate::crop_ui::shows_beyond_canvas(app)); let Some(f) = frame else { @@ -4915,8 +5540,9 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, pts.push(h); } } - // Just the outline and its rubber band: the vertices aren't handles to grab. - crate::tool_feedback::draw_ants(painter, &pts, false); + // Just the outline and its rubber band (the vertices aren't handles to grab), solid until + // the selection is made (#7495). + crate::tool_feedback::draw_solid_path(painter, &pts, false); } crate::crop_straighten::draw(app, painter, |p| xf.to_screen(p[0] as f32, p[1] as f32), hover); crate::blur_pins::draw(app, painter, xf); @@ -4953,15 +5579,18 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option