diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b06e15449d..11b4b9ef05 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,52 +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, native saves, build scripts or - # the lockfile. Format changes must exercise the Windows-only publication barriers (#8034). - # 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 @@ -80,66 +108,82 @@ jobs: echo "web=false" >> "$GITHUB_OUTPUT" fi if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then - echo "legs=$all" >> "$GITHUB_OUTPUT" + 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: abb83316d96aa59c1cf64784289e378fe9fa5695 + # 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" steps: - - uses: actions/checkout@v4 - - # 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 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + 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: | @@ -148,149 +192,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 @@ -306,7 +281,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: @@ -314,7 +291,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') }} @@ -323,23 +300,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 @@ -351,7 +311,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 @@ -409,6 +371,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 } @@ -419,7 +382,9 @@ jobs: - { crate: affinity, name: import_preview, args: "--features io" } - { crate: format, name: load } steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: @@ -433,7 +398,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/freebsd.yml b/.github/workflows/freebsd.yml index 56d1ad0831..f15ec0e8d8 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: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d8..e78fe1ea3b 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ 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: 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 d8244b4fec..5b2b1e1470 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -88,6 +88,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | | `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below | | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | +| `docs/images/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)): diff --git a/Cargo.lock b/Cargo.lock index ba0e6baea0..b27d26a78a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,12 +241,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.60.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.60.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -993,6 +1037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -1001,8 +1046,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 +1079,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 +1157,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" @@ -1227,6 +1311,14 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-jpeg" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -3066,6 +3158,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" @@ -4403,6 +4501,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" @@ -4638,10 +4742,12 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.12.0" +version = "0.15.1" dependencies = [ "arboard", "async-io", + "chrono", + "clap", "core-graphics2", "dispatch2", "eframe", @@ -4672,6 +4778,8 @@ dependencies = [ "ron", "serde_json", "tempfile", + "thiserror 2.0.21", + "ureq", "winit", "winresource", "winsafe", @@ -4683,7 +4791,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.12.0" +version = "0.15.1" dependencies = [ "crc32fast", "flate2", @@ -4693,11 +4801,12 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.12.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", + "photocraft-testkit", "rayon", "rustfft", "serde", @@ -4706,10 +4815,11 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.12.0" +version = "0.15.1" dependencies = [ "base64 0.22.1", "cap-std", + "clap", "getrandom 0.3.4", "photocraft-codecs", "photocraft-compose", @@ -4729,8 +4839,11 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.12.0" +version = "0.15.1" dependencies = [ + "clap", + "clap_complete", + "clap_mangen", "photocraft-automation", "photocraft-codecs", "photocraft-engine", @@ -4738,13 +4851,14 @@ dependencies = [ "photocraft-io", "photocraft-raw", "serde_json", + "thiserror 2.0.21", "tokio", "winresource", ] [[package]] name = "photocraft-cms" -version = "0.12.0" +version = "0.15.1" dependencies = [ "moxcms", "rayon", @@ -4752,8 +4866,9 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.12.0" +version = "0.15.1" dependencies = [ + "craft-jpeg", "exr", "flate2", "half", @@ -4773,7 +4888,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.12.0" +version = "0.15.1" dependencies = [ "photocraft-cms", "serde", @@ -4781,9 +4896,10 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.12.0" +version = "0.15.1" dependencies = [ "criterion", + "photocraft-cms", "photocraft-color", "photocraft-doc", "photocraft-geom", @@ -4796,7 +4912,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.12.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4807,9 +4923,10 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.12.0" +version = "0.15.1" dependencies = [ "blake3", + "chrono", "craft-print", "exr", "flate2", @@ -4830,11 +4947,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", @@ -4845,7 +4964,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.12.0" +version = "0.15.1" dependencies = [ "blake3", "crc32fast", @@ -4868,14 +4987,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.12.0" +version = "0.15.1" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.12.0" +version = "0.15.1" dependencies = [ "half", "log", @@ -4893,14 +5012,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.12.0" +version = "0.15.1" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.12.0" +version = "0.15.1" dependencies = [ "blake3", "craft-docpath", @@ -4919,29 +5038,30 @@ 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", ] [[package]] name = "photocraft-ipp" -version = "0.12.0" +version = "0.15.1" [[package]] name = "photocraft-ops" -version = "0.12.0" +version = "0.15.1" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.12.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4952,12 +5072,13 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.12.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", "rayon", + "rustix 1.1.5", "serde", "serde_json", "thiserror 2.0.21", @@ -4967,7 +5088,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.12.0" +version = "0.15.1" dependencies = [ "flate2", "photocraft-psd", @@ -4978,7 +5099,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.12.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4989,7 +5110,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.12.0" +version = "0.15.1" dependencies = [ "flate2", "image", @@ -5004,7 +5125,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.12.0" +version = "0.15.1" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -5016,7 +5137,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.12.0" +version = "0.15.1" dependencies = [ "memory-stats", "serde_json", @@ -5024,7 +5145,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.12.0" +version = "0.15.1" dependencies = [ "flate2", "parley", @@ -5039,7 +5160,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.12.0" +version = "0.15.1" dependencies = [ "base64 0.22.1", "craft-print", @@ -5082,7 +5203,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.12.0" +version = "0.15.1" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5093,7 +5214,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.12.0" +version = "0.15.1" dependencies = [ "eframe", "egui", @@ -5115,7 +5236,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.12.0" +version = "0.15.1" dependencies = [ "flate2", "js-sys", @@ -5949,6 +6070,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" @@ -6051,12 +6178,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", @@ -7452,6 +7581,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", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[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" @@ -7491,12 +7649,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" @@ -7930,6 +8100,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -8711,8 +8890,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.12.0" +version = "0.15.1" dependencies = [ + "chrono", "serde", "serde_json", "toml", diff --git a/Cargo.toml b/Cargo.toml index c3fdd1e10c..68d6ae90b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.12.0" +version = "0.15.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -41,9 +41,13 @@ 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-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } 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"] } @@ -59,6 +63,8 @@ egui = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" criterion = { version = "0.5", default-features = false } getrandom = "0.3" @@ -70,15 +76,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 diff --git a/README.md b/README.md index 4285f69513..74a5c78f36 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 1d1174c504..829ff75af8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,7 +137,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 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/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) | +| 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 | diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 70f15d5be1..94e04b518a 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -28,12 +28,18 @@ 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. 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 c2497e1bf1..c431b5529f 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -3,234 +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>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). - An that is the file is refused, as the result would replace the original; - --in-place allows it. - TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data); - --tiff-compression compresses it (none, the default; lzw; zip). - --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. - --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as - its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. - --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. - --dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1 - (DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain. - --jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized - (Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so - only Gray and CMYK shrink) or progressive. - photocraft-cli info [--compact] - Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Open a file, run engine commands in order, save the result. Each --params - applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] - Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a - set: its action --action names plays, as below. JSON steps are [id, params] pairs, - {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them - (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", "--tiff-compression", "--jpeg-format"], - bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], - run: convert, - }, - Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, - Subcommand { - name: "run", - values: &[ - "--new", - "--cmd", - "--params", - "--out", - "--format", - "--quality", - "--tga-bits", - "--dpx-bits", - "--dds-compression", - "--tiff-compression", - "--jpeg-format", - ], - bare: &["--tiff-layers", "--dds-mipmaps"], - run: run_cmds, - }, - Subcommand { - name: "batch", - values: &[ - "--actions", - "--action", - "--in", - "--out", - "--format", - "--quality", - "--tga-bits", - "--dpx-bits", - "--dds-compression", - "--tiff-compression", - "--jpeg-format", - ], - 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}"); @@ -239,640 +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("--tiff-compression") { - o.encode.tiff_compression = match c { - "none" => photocraft_codecs::TiffCompression::None, - "lzw" => photocraft_codecs::TiffCompression::Lzw, - "zip" => photocraft_codecs::TiffCompression::Deflate, - _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")), - }; - } - if let Some(f) = a.get("--jpeg-format") { - o.encode.jpeg_format = - photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?; - } - if let Some(c) = a.get("--dds-compression") { - o.encode.dds.compression = - photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?; - } - o.encode.dds.mipmaps = a.has("--dds-mipmaps"); - Ok(o) -} - -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()) -} - -/// An output that is the input file itself, however it is spelt (relative, with `./`, through a -/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it -/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in` -/// folder (#7437). The paths are compared when the command starts and the save opens `output` -/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards -/// against mistakes, it is not a security boundary. -fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R { - if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) { - return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it")); - } - Ok(()) -} - -fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { - let [input, output] = a.positional.as_slice() else { - return Err("convert needs ".into()); - }; - let opts = export_opts(a)?; - refuse_overwriting_input(a, input, output)?; - let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?; - // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. - photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); - warn_all(err, &o.warnings); - warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); - let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?; - 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/it/cli.rs similarity index 58% rename from apps/photocraft-cli/tests/cli.rs rename to apps/photocraft-cli/tests/it/cli.rs index c31239d14f..68547951ef 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -6,10 +6,17 @@ 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 { - Command::new(env!("CARGO_BIN_EXE_photocraft-cli")) + let mut cmd = Command::new(env!("CARGO_BIN_EXE_photocraft-cli")); + 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); @@ -23,23 +30,33 @@ fn write_png(path: &Path, w: u32, h: u32, seed: u8) { std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); } -fn ok(cmd: &mut Command) -> (String, String) { +/// What a successful run printed. +struct Printed { + stdout: String, + stderr: String, +} + +fn ok(cmd: &mut Command) -> Printed { 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) + 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("USAGE")); + assert!(String::from_utf8_lossy(&o.stderr).contains("convert")); let o = bin().arg("frobnicate").output().unwrap(); assert_eq!(o.status.code(), Some(2)); - let (out, _) = ok(bin().arg("--help")); + // `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")); + let out = ok(bin().arg("--version")).stdout; assert!(out.starts_with("photocraft-cli ")); } @@ -53,9 +70,23 @@ fn every_subcommand_answers_help() { 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!(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"); } @@ -73,7 +104,7 @@ fn unknown_flags_are_usage_errors() { 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}"); + 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"); @@ -86,13 +117,29 @@ fn unknown_flags_are_usage_errors() { 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")); + 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"])); + 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])); + 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([ @@ -101,10 +148,83 @@ fn unknown_flags_are_usage_errors() { "80", ])); assert!(out_dir.join("a.jpg").exists()); - let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact")); + 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"); @@ -128,7 +248,7 @@ fn convert_errors() { 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)); + assert_eq!(o.status.code(), Some(2)); std::fs::remove_dir_all(d).unwrap(); } @@ -137,14 +257,14 @@ 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 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")); + 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(); } @@ -173,10 +293,10 @@ fn info_and_convert_report_decode_warnings() { 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 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")))); + 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(), @@ -194,18 +314,19 @@ fn info_and_convert_report_decode_warnings() { fn run_commands_and_save() { let d = tmp("run"); let out_pc = d.join("r.pcraft"); - let (out, _) = ok(bin() + 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)); + .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)); + 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"); @@ -246,13 +367,14 @@ fn run_stops_after_failed_action_without_exporting() { fn run_continues_after_successful_action_and_exports() { let d = tmp("run-successful-action"); let out = d.join("result.png"); - let (stdout, _) = ok(bin() + 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)); + .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); @@ -267,10 +389,13 @@ 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(1)); + 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(1)); + 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)); @@ -310,10 +435,10 @@ fn batch_applies_actions_to_directory() { 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"])); + 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")))); + 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"); } @@ -347,7 +472,7 @@ fn batch_skips_recorded_view_and_save_steps() { 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 = 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"); @@ -433,8 +558,8 @@ fn batch_format_with_leading_dot() { } } -/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size, -/// and nothing is written. +/// #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"); @@ -450,12 +575,12 @@ fn quality_outside_1_to_100_is_refused() { 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_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(1), "batch --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. @@ -471,7 +596,7 @@ fn quality_outside_1_to_100_is_refused() { } /// 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). +/// 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"); @@ -488,7 +613,7 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() { 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 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}"); @@ -498,15 +623,16 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() { 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}"); + 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 refused and nothing is written (#1327). +/// 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"); @@ -531,14 +657,14 @@ fn dds_converts_both_ways_with_the_compression_asked_for() { 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_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 -/// refused and nothing is written. +/// `--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"); @@ -557,14 +683,55 @@ fn tga_bits_choose_the_targa_depth() { 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}"); + 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 refused and nothing is written. +/// doesn't name is a usage error and nothing is written. #[test] fn jpeg_format_chooses_the_scans() { let d = tmp("jpeg-format"); @@ -584,8 +751,9 @@ fn jpeg_format_chooses_the_scans() { for bad in ["Progressive", "1", ""] { let out = d.join("bad.jpg"); let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap(); - assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}"); - assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}"); + 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"); } } @@ -625,7 +793,7 @@ fn batch_refuses_to_write_over_its_inputs() { } } // 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"])); + 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. @@ -667,16 +835,16 @@ fn convert_refuses_to_write_over_its_input() { assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before); // `--in-place` opts in: the result replaces the original, which still opens. ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"])); - let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])); + let 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")); + let out = ok(bin().arg("commands")).stdout; assert!(out.contains("file.new")); - let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.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() @@ -690,7 +858,7 @@ fn commands_listing() { 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); + 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")); } @@ -700,20 +868,8 @@ 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; - } - } - }; + 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); @@ -733,6 +889,61 @@ fn mcp_stdio_handshake_and_tool_call() { 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() { @@ -761,7 +972,7 @@ fn batch_fails_inputs_that_share_an_output_name() { 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)); + 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}"); } @@ -777,7 +988,7 @@ fn droplet_runs_an_action_on_files() { ) .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)); + 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)); @@ -789,7 +1000,7 @@ fn droplet_runs_an_action_on_files() { 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 info = ok(bin().arg("info").arg(path)).stdout; let v: Value = serde_json::from_str(&info).unwrap(); v["layers"].as_array().unwrap().len() }; @@ -815,19 +1026,38 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() { } /// 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. +/// `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_argument_is_a_usage_error_not_a_panic() { +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 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 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(2), "{err}"); - assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}"); + 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 @@ -850,7 +1080,7 @@ fn batch_plays_a_photoshop_actions_file() { 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)); + 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. @@ -860,31 +1090,40 @@ fn batch_plays_a_photoshop_actions_file() { assert!(!o.status.success()); } -/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`. -fn invoke(args: &[&str]) -> (i32, String, String) { - let (mut out, mut err) = (Vec::new(), Vec::new()); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - let code = photocraft_cli::run(&args, &mut out, &mut err); - (code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap()) +/// 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 goes to stdout (exit 0), no arguments is a usage error on stderr. +/// 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 (code, out, err) = invoke(&[]); + 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 (code, out, err) = invoke(&[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", "version"] { - let (code, out, err) = invoke(&[version]); + 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 "), "{version}: {out}"); + assert!(out.starts_with("photocraft-cli ") && out.ends_with('\n') && out.lines().count() == 1, "{version}: {out}"); } - let (code, out, err) = invoke(&["frobnicate"]); + 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}"); } @@ -895,7 +1134,7 @@ fn top_level_help_version_and_no_arguments() { fn every_subcommand_help_returns_on_stdout() { for sub in SUBCOMMANDS { for help in ["--help", "-h"] { - let (code, out, err) = invoke(&[sub, help]); + 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}"); @@ -909,34 +1148,47 @@ fn every_subcommand_rejects_an_unknown_flag() { for sub in SUBCOMMANDS { for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] { let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect(); - let (code, out, err) = invoke(&args); + 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, and repeated flags. +/// `--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 (code, eq, err) = invoke(&["commands", "--filter=file.new"]); + let Invoke { code, stdout: eq, stderr: err } = invoke(&["commands", "--filter=file.new"]); assert_eq!(code, 0, "{err}"); - let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]); + let spaced = invoke(&["commands", "--filter", "file.new"]).stdout; assert_eq!(eq, spaced); assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}"); - // The last of a repeated value flag wins, whichever order they come in. - let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]); + // 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}"); - assert_eq!(out, eq); - let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]); - assert_eq!(code, 0, "{err}"); - assert!(out.is_empty(), "{out}"); // A bare flag takes no value; a value flag needs one. - let (code, out, err) = invoke(&["commands", "--json=1"]); + 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 (code, out, err) = invoke(&["commands", "--filter"]); + let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter"]); assert_eq!((code, out.is_empty()), (2, true)); assert!(err.contains("--filter"), "{err}"); } @@ -945,7 +1197,7 @@ fn flag_value_forms_and_repeats() { /// `--params` ahead of every `--cmd` fails naming `--params`. #[test] fn run_pairs_params_with_the_preceding_cmd() { - let (code, out, err) = invoke(&[ + let Invoke { code, stdout: out, stderr: err } = invoke(&[ "run", "--new", r#"{"width":8,"height":8}"#, @@ -973,7 +1225,7 @@ fn run_pairs_params_with_the_preceding_cmd() { // Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params // with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead. assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}"); - let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]); + let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]); assert_ne!(code, 0); assert!(err.contains("--params"), "{err}"); } @@ -992,3 +1244,180 @@ fn convert_opens_a_raw_at_the_raw_defaults_resolution() { 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..60cea6f74b --- /dev/null +++ b/apps/photocraft-cli/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, linked into one test binary. + +mod cli; +mod man_and_completions; +mod orientation; 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 100% rename from apps/photocraft-cli/tests/orientation.rs rename to apps/photocraft-cli/tests/it/orientation.rs diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 4ecad80ae4..279d13b453 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -34,7 +34,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "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/web.rs b/apps/photocraft-web/src/web.rs index fc17612908..20e64fa046 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -17,12 +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", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic", - "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", - "afphoto", "afpub", + "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"; @@ -233,20 +233,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())); diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 61c43ae9da..d8e7568300 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -33,6 +33,9 @@ eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +thiserror = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } @@ -40,6 +43,8 @@ arboard = { version = "3.6", 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. +ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier"] } [target.'cfg(target_os = "macos")'.dependencies] # Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target. 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 1b58e89f03..7fbf765129 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")] @@ -74,6 +69,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; @@ -147,360 +143,30 @@ 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), - } - } - 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, + // `--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` @@ -516,7 +182,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 @@ -597,7 +263,7 @@ fn main() -> eframe::Result { } 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) => { @@ -612,7 +278,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}"); @@ -701,7 +367,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. @@ -779,7 +445,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() }; @@ -937,9 +603,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(); 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 cb7dae9fc5..51cf217faf 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -15,13 +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", "dcm", "dicom", "dc3", "iff", "lbm", - "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", - "kys", "af", "afdesign", "afphoto", "afpub", + "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"]; @@ -71,6 +71,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[ ("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"]), ]; @@ -560,6 +561,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(); @@ -624,6 +631,7 @@ pub fn native(automation: Option) -> inbox: None, open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))), beep: Some(platform_beep), + check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()), clipboard_set_image: Some({ let clip = clip.clone(); guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| { @@ -903,6 +911,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. diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs index 953da20745..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. @@ -131,20 +133,19 @@ pub struct LaunchContext<'a> { pub unreadable_paths: bool, /// Options that only apply to the process they're given to (`--safe-gpu`, …), including the /// ones this launch's environment sets (see [`env_launch_options`]). - pub launch_options: &'a [&'static str], + pub launch_options: &'a [PerProcess], } -/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for +/// 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. -const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [ - ("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on), - ("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on), - ("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on), - ("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on), - ("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set), - ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set), +/// 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 @@ -169,29 +170,15 @@ fn stroke_trace_is_on(value: &str) -> bool { !matches!(value.trim(), "" | "0" | "off" | "false") } -/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is -/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does -/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too. -fn flag_is_on(value: &str) -> bool { - !value.is_empty() && value != "0" -} - -/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty -/// root is read as no root unless `--control` is given, and that flag keeps its own process already. -fn path_is_set(value: &str) -> bool { - !value.is_empty() -} - -/// The per-process options this launch's environment sets, named as the flags they stand for. A -/// launch that sets only variables would otherwise hand its files to the running instance, which -/// never sees the override, and exit. -pub fn env_launch_options() -> Vec<&'static str> { +/// 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<&'static str> { - ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect() +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. @@ -209,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 } @@ -898,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 @@ -983,30 +971,25 @@ mod tests { #[test] fn an_environment_override_counts_as_a_process_option() { let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into())); - assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]); + assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec![PerProcess::PresentMode]); assert_eq!( - with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]), - vec!["--stroke-trace", "--automation-write-root"] + 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 six. + // it is read where it takes effect, not by one rule shared across all three. assert!(with(&[]).is_empty()); - assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).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()); - assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty()); - // Spaces are part of the value only where the reader keeps them: the trace and the two - // presentation values are trimmed where they take effect, the menu flag and a path are not. - assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]); + // The trace and the two presentation values are trimmed where they take effect. assert_eq!( - with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]), - vec!["--in-window-menus", "--automation-write-root"] + with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), + vec![PerProcess::PresentMode, PerProcess::StrokeTrace] ); - // A whitespace-only menu value is not empty, so it enables the menus there and here too. - assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]); - // `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too. - assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]); + // 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, diff --git a/apps/photocraft/src/updates.rs b/apps/photocraft/src/updates.rs new file mode 100644 index 0000000000..bbe4a9f3dc --- /dev/null +++ b/apps/photocraft/src/updates.rs @@ -0,0 +1,126 @@ +//! Native HTTPS transport for release checks. Only public release metadata is requested. + +use photocraft_engine::updates_cmds::{CheckFn, CheckResult, parse_release}; +use std::time::Duration; + +const LATEST: &str = "https://api.github.com/repos/storytold/photocraft/releases/latest"; +const MAX_RESPONSE: u64 = 1024 * 1024; + +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 = fetch(LATEST, Duration::from_secs(15)); + 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 fetch(url: &str, timeout: Duration) -> CheckResult { + // Respect the OS trust store (including managed-network certificates), while still + // validating the certificate chain and server name. + let agent = ureq::Agent::config_builder() + .tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build()) + .timeout_global(Some(timeout)) + .max_redirects(0) + .build() + .new_agent(); + let mut response = match agent + .get(url) + .header("User-Agent", "PhotoCraft-update-check") + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2026-03-10") + .call() + { + Ok(response) => response, + // The repository may not have published a stable release yet. + Err(ureq::Error::StatusCode(404)) => return Ok(None), + Err(ureq::Error::StatusCode(403 | 429)) => return Err("GitHub is limiting update checks. Please try again later.".into()), + Err(e) => return Err(format!("Could not contact the release server: {e}")), + }; + if response.status() != 200 { + return Err(format!("Unexpected release server status: {}", response.status())); + } + let bytes = response.body_mut().with_config().limit(MAX_RESPONSE).read_to_vec().map_err(|e| format!("Could not read release response: {e}"))?; + parse_release(&bytes, photocraft_engine::build_info::VERSION) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + fn server(status: &str, body: &str, extra: &str) -> (String, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/latest", listener.local_addr().unwrap()); + let response = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra}Connection: close\r\n\r\n{body}", body.len()); + let worker = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut chunk).unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + } + let _ = stream.write_all(response.as_bytes()); + String::from_utf8(request).unwrap() + }); + (url, worker) + } + + #[test] + fn fetches_release_metadata_with_generic_headers() { + let (url, worker) = server("200 OK", r#"{"tag_name":"v99.0.0","draft":false,"prerelease":false}"#, ""); + let result = fetch(&url, Duration::from_secs(5)).unwrap().unwrap(); + assert_eq!(result.version, "99.0.0"); + let request = worker.join().unwrap().to_ascii_lowercase(); + assert!(request.contains("user-agent: photocraft-update-check\r\n")); + assert!(request.contains("accept: application/vnd.github+json")); + assert!(!request.contains("authorization:") && !request.contains("cookie:")); + } + + #[test] + fn handles_missing_release_rate_limits_and_bad_responses() { + for (status, body, extra, expected_none) in [ + ("404 Not Found", "", "", true), + ("403 Forbidden", "", "", false), + ("429 Too Many Requests", "", "", false), + ("500 Internal Server Error", "", "", false), + ("200 OK", "invalid JSON", "", false), + ("302 Found", "", "Location: https://untrusted.example/\r\n", false), + ] { + let (url, worker) = server(status, body, extra); + let result = fetch(&url, Duration::from_secs(5)); + if expected_none { + assert_eq!(result, Ok(None)); + } else { + assert!(result.is_err(), "{status}"); + } + worker.join().unwrap(); + } + } + + #[test] + fn caps_response_size_and_times_out() { + let (url, worker) = server("200 OK", &" ".repeat(MAX_RESPONSE as usize + 1), ""); + assert!(fetch(&url, Duration::from_secs(5)).is_err()); + worker.join().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/latest", listener.local_addr().unwrap()); + let start = std::time::Instant::now(); + assert!(fetch(&url, Duration::from_millis(100)).is_err()); + assert!(start.elapsed() < Duration::from_secs(2)); + } +} 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/it/command_line_usage.rs b/apps/photocraft/tests/it/command_line_usage.rs new file mode 100644 index 0000000000..d9afc64194 --- /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(env!("CARGO_BIN_EXE_photocraft")); + 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 63% rename from apps/photocraft/tests/control_exit_code.rs rename to apps/photocraft/tests/it/control_exit_code.rs index 280afac261..9494dbb43f 100644 --- a/apps/photocraft/tests/control_exit_code.rs +++ b/apps/photocraft/tests/it/control_exit_code.rs @@ -9,10 +9,21 @@ /// 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())); @@ -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..b5ece12621 --- /dev/null +++ b/apps/photocraft/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, linked into one test binary. + +mod command_line_usage; +mod control_exit_code; +mod startup_without_display; diff --git a/apps/photocraft/tests/startup_without_display.rs b/apps/photocraft/tests/it/startup_without_display.rs similarity index 100% rename from apps/photocraft/tests/startup_without_display.rs rename to apps/photocraft/tests/it/startup_without_display.rs diff --git a/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/contributors/people.toml b/contributors/people.toml index 30077a230f..242fb5ac80 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -79,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 @@ -251,3 +254,13 @@ 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" 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_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/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..665f6fd6e9 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -86,6 +86,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 +561,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 +571,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 +698,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 +719,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 +1000,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 +1105,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 +1142,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 +1207,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 +1229,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 ee678d1aa9..5d35f5791b 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -191,6 +191,9 @@ struct StrokeCursor { /// 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 { @@ -209,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 } } @@ -584,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; @@ -651,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`. @@ -671,19 +687,24 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface { 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 { @@ -765,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(); @@ -1122,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. @@ -1300,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/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/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..0cbd9a66a2 100644 --- a/crates/algo/src/seam.rs +++ b/crates/algo/src/seam.rs @@ -359,19 +359,18 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: }); let seam_count = w - new_w; - let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true); - let mut removed = Vec::with_capacity(seam_count); + // Removing a seam shifts the panel left, so a panel column is not an original one. Tracking + // the original column per pixel costs a third buffer that every seam has to shift (24 MB a + // seam at 24 MP); counting the columns already taken from that row is exact and costs a + // binary search per removed column. + let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, false); + let mut removed_by_row: Vec> = vec![Vec::new(); proxy_h]; + let mut taken = 0usize; while carver.cw > new_w { ctl.check()?; - removed.push(carver.remove_seam()); - ctl.progress(removed.len() as f32 / seam_count as f32); - } - - let mut removed_by_row = Vec::with_capacity(proxy_h); - for proxy_y in 0..proxy_h { - let mut columns: Vec = removed.iter().map(|path| path[proxy_y]).collect(); - columns.sort_unstable(); - removed_by_row.push(columns); + record_removed(&mut removed_by_row, &carver.remove_seam()); + taken += 1; + ctl.progress(taken as f32 / seam_count as f32); } let mut out = Vec::with_capacity(new_w * h * ch); let mut columns = vec![0; seam_count]; @@ -403,6 +402,32 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: Ok(out) } +/// Add one seam, given in panel columns, to each row's sorted list of removed original columns. +/// For the `i`-th column already taken from a row, `o - i` is the panel column it occupied, and +/// that never decreases along the list, so the taken columns at or left of panel column `x` are a +/// prefix found by binary search. `x` plus that prefix's length is the original column, and every +/// removal the search passed is left of it, so it inserts where the search stopped. The rows are +/// independent, so they update in parallel like the pixel shift. +fn record_removed(removed_by_row: &mut [Vec], seam: &[usize]) { + // The entries are the original columns still taken from one row, sorted, so entry `i` is at + // least `i` and `o - i` cannot underflow. + let add = |(row, &x): (&mut Vec, &usize)| { + let (mut lo, mut hi) = (0, row.len()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if row[mid] - mid <= x { lo = mid + 1 } else { hi = mid } + } + row.insert(lo, x + lo); + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + removed_by_row.par_iter_mut().zip(seam.par_iter()).for_each(add); + } + #[cfg(target_arch = "wasm32")] + removed_by_row.iter_mut().zip(seam.iter()).for_each(add); +} + /// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing). pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec { // Never cancelled; the fallback is unreachable. @@ -761,6 +786,33 @@ mod tests { assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled)); } + /// The proxy path counts each removed original column instead of shifting an index map, so a + /// tracking and a counting carver must agree: run both side by side down to one column and + /// check every row's list after every seam (tie-heavy noise gives rows different histories; + /// 1-4 channels, with and without protection). + #[test] + fn counted_removed_columns_match_the_tracked_map() { + let cases: [(usize, usize, usize, u32, bool); 5] = + [(37, 9, 1, 2, false), (40, 12, 2, 3, true), (53, 7, 3, 255, false), (29, 16, 4, 1000, true), (64, 5, 3, 2, true)]; + for (i, &(w, h, ch, levels, prot)) in cases.iter().enumerate() { + let img = noise(w * h * ch, levels, 31 + i as u32); + let protect = prot.then(|| noise(w * h, 3, 77 + i as u32)); + let mut tracked = Carver::new(w, h, ch, img.clone(), protect.clone(), true); + let mut counted = Carver::new(w, h, ch, img, protect, false); + let mut expected: Vec> = vec![Vec::new(); h]; + let mut got: Vec> = vec![Vec::new(); h]; + while tracked.cw > 1 { + let seam = tracked.remove_seam(); + for (row, &x) in expected.iter_mut().zip(seam.iter()) { + let at = row.partition_point(|&o| o < x); + row.insert(at, x); + } + record_removed(&mut got, &counted.remove_seam()); + assert_eq!(got, expected, "case {i} after {} seams", w - tracked.cw); + } + } + } + #[test] fn enlarging_duplicates_flat_areas() { let (w, h) = (30, 8); 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 165272231b..135c47b223 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -903,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/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..3ca4eacd52 --- /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(|a, b| a.total_cmp(b)); + }; + 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/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/warp.rs b/crates/algo/src/warp.rs index dd20daf6c9..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); @@ -510,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 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/liquify_source_footprint.rs b/crates/algo/tests/it/liquify_source_footprint.rs similarity index 100% rename from crates/algo/tests/liquify_source_footprint.rs rename to crates/algo/tests/it/liquify_source_footprint.rs 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 ae1837cdd3..5421640a84 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -25,6 +25,7 @@ base64 = "0.22" schemars = "1" getrandom = { workspace = true } cap-std = "4" +clap = { workspace = true } [dev-dependencies] # Synthetic camera raws (#7789). 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 1e3e83e1bf..6722c5c00b 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -152,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 @@ -171,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) }) } @@ -261,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 c0369c917d..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)] @@ -724,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())); @@ -800,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(); @@ -817,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), @@ -836,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(); @@ -874,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..f5ccef00c7 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 { @@ -336,7 +350,8 @@ 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). Other fields + /// are an error. pub fields: Value, } @@ -412,6 +427,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 +660,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 +876,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 +1129,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/workspace.rs b/crates/automation/src/workspace.rs index 90fbdcd1ab..e8566b439e 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -398,12 +398,13 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool { | "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" | "layer.layerStyle.contours.save" => 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()) @@ -845,6 +846,9 @@ 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"})), ] { @@ -859,6 +863,8 @@ 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"]})), ] { @@ -947,6 +953,7 @@ mod tests { | "path.rename" | "path.stroke" | "type.attachToPath" + | "type.fillPath" | "type.detachFromPath" | "select.toWorkPath" | "layer.vectorMask.add" @@ -993,6 +1000,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/agent_tasks_extended.rs b/crates/automation/tests/it/agent_tasks_extended.rs similarity index 100% rename from crates/automation/tests/agent_tasks_extended.rs rename to crates/automation/tests/it/agent_tasks_extended.rs 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 88d62f6595..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,6 +799,43 @@ 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() { @@ -1043,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/raw_defaults.rs b/crates/automation/tests/it/raw_defaults.rs similarity index 100% rename from crates/automation/tests/raw_defaults.rs rename to crates/automation/tests/it/raw_defaults.rs diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/it/smart_filter_authorization.rs similarity index 100% rename from crates/automation/tests/smart_filter_authorization.rs rename to crates/automation/tests/it/smart_filter_authorization.rs 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 240aac36f8..dbe6281d8f 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -221,7 +221,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(); @@ -456,6 +463,38 @@ DOMAIN_MAX 0.5 1 1 assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data); } + #[test] + fn look_rgb_and_rgba_allow_whitespace_in_hex_data() { + let id = LutFile::identity(2); + for channels in [3, 4] { + let mut hex = String::new(); + for rgb in id.data.as_chunks::<3>().0 { + for value in rgb.iter().copied().chain((channels == 4).then_some(1.0)) { + for byte in value.to_le_bytes() { + hex.push_str(&format!("{byte:02x} \t\r\n")); + } + } + } + let xml = format!("2\"{hex}\""); + assert_eq!(parse("valid.look", xml.as_bytes()).unwrap().data, id.data); + } + } + + #[test] + fn look_rejects_partial_floats_and_invalid_hex() { + let id = LutFile::identity(2); + let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + for suffix in ["0", "00", "0000", "000000", "G", "é"] { + let xml = format!("2{hex}{suffix}"); + assert!(parse("bad.look", xml.as_bytes()).is_err(), "accepted malformed suffix {suffix:?}"); + } + let invalid = hex.replacen('0', "G", 1); + let xml = format!("2{invalid}"); + assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("invalid hexadecimal")); + let xml = format!("2{hex}00"); + assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("incomplete float")); + } + #[test] fn look_3d_size_exponent_overflow_is_rejected() { let text = format!("{}00", usize::MAX); diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index cdd7d5273a..01b2327df8 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -184,6 +184,7 @@ pub struct Profile { impl Profile { /// Parses ICC profile bytes. pub fn parse(bytes: &[u8]) -> Result { + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); let mut p = parse_profile(bytes)?; p.bytes = Some(Arc::new(bytes.to_vec())); Ok(p) @@ -211,7 +212,9 @@ impl Profile { self.color_space.channels() } - /// Hash of the profile's ICC bytes (stable within a process; used as a cache key). + /// Hash of the profile's ICC bytes (stable within a process; used as a cache key). It is a + /// fixed-key 64-bit hash, so a crafted profile can share another's: equal hashes only narrow + /// the search, [`Profile::same_bytes`] decides. pub fn content_hash(&self) -> u64 { *self.hash.get_or_init(|| { use std::hash::{Hash, Hasher}; @@ -221,6 +224,16 @@ impl Profile { }) } + /// Whether `self` and `other` have the same ICC bytes. + pub fn same_bytes(&self, other: &Profile) -> bool { + if let (Some(a), Some(b)) = (&self.bytes, &other.bytes) + && Arc::ptr_eq(a, b) + { + return true; + } + self.content_hash() == other.content_hash() && self.to_bytes() == other.to_bytes() + } + /// Whether `self` and `other` describe the same colours: same colour space, and every sample /// of a device-value grid has the same colour through both (relative colorimetric, within /// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the @@ -228,7 +241,7 @@ impl Profile { /// parametric curves) differ in every byte. Colours are compared rather than round-tripped /// because CMYK → PCS → CMYK is not an identity even through a single profile. pub fn same_colors(&self, other: &Profile) -> bool { - if self.content_hash() == other.content_hash() { + if self.same_bytes(other) { return true; } let n = self.channels(); @@ -573,6 +586,24 @@ fn parse_profile(bytes: &[u8]) -> Result { Ok(p) } +/// Sampled-curve entries one profile may decode in all (64 MB of `f32`). Tags and curve groups can +/// point at the same encoded bytes, and each reference decodes its own table. +const MAX_CURVE_SAMPLES: usize = 16 << 20; + +thread_local! { + /// What is left of [`MAX_CURVE_SAMPLES`] for the profile being parsed on this thread. + static CURVE_SAMPLES_LEFT: std::cell::Cell = const { std::cell::Cell::new(MAX_CURVE_SAMPLES) }; +} + +/// Charges `n` decoded curve samples to the profile being parsed. +fn charge_curve_samples(n: usize) -> Result<(), CmsError> { + CURVE_SAMPLES_LEFT.with(|left| { + let rest = left.get().checked_sub(n).ok_or_else(|| CmsError::Invalid("the profile's curves hold too many samples".into()))?; + left.set(rest); + Ok(()) + }) +} + /// Parses a `curv` or `para` element; returns the curve and the element's byte length /// (unpadded). fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { @@ -590,6 +621,7 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { 1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)), _ => { let s = r.slice(12, n * 2)?; + charge_curve_samples(n)?; let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(); Ok((Curve::Table(t), len)) } @@ -818,6 +850,7 @@ fn parse_text(d: &[u8]) -> String { #[cfg(test)] mod tests { use super::*; + use crate::builtin::Builtin; fn be(v: u32) -> [u8; 4] { v.to_be_bytes() @@ -903,4 +936,71 @@ mod tests { d.extend(be(u32::MAX)); assert_eq!(parse_text(&d), ""); } + + /// `p` with the content hash of `other`: the hash is a fixed-key 64-bit SipHash, so a + /// crafted profile can have another's (#8613). Setting it stands in for finding one. + fn with_hash_of(p: &Profile, other: &Profile) -> Profile { + let p = Profile { hash: std::sync::OnceLock::from(other.content_hash()), ..p.clone() }; + assert_eq!(p.content_hash(), other.content_hash()); + p + } + + /// #8613: a profile that shares sRGB's hash is not taken for sRGB. + #[test] + fn a_hash_collision_is_not_the_same_colours() { + let srgb = Builtin::Srgb.profile(); + let p3 = with_hash_of(Builtin::DisplayP3.profile(), srgb); + assert!(!p3.same_bytes(srgb)); + assert!(!p3.same_colors(srgb)); + // Controls: equal bytes are the same, whatever object holds them. + assert!(srgb.same_bytes(srgb)); + assert!(srgb.same_bytes(&Profile::parse(&srgb.to_bytes()).unwrap())); + assert!(Builtin::DisplayP3.profile().same_colors(Builtin::DisplayP3.profile())); + } + + /// #8613: the transform cache does not hand a colliding profile the other's transform. + #[test] + fn a_hash_collision_does_not_get_the_cached_transform() { + use crate::transform::{Transform, TransformOptions, cached}; + let (srgb, lab) = (Builtin::Srgb.profile(), Builtin::LabD50.profile()); + let p3 = with_hash_of(Builtin::DisplayP3.profile(), srgb); + let opts = TransformOptions::default(); + let green = [0.0, 1.0, 0.0]; + let eval = |t: &Transform| { + let mut out = [0f32; 3]; + t.eval(&green, &mut out); + out + }; + let want = eval(&Transform::with_options(Builtin::DisplayP3.profile(), lab, opts).unwrap()); + assert_ne!(want, eval(&Transform::with_options(srgb, lab, opts).unwrap()), "P3 green is not sRGB green"); + let sr = eval(&cached(srgb, lab, opts).unwrap()); + assert_eq!(eval(&cached(&p3, lab, opts).unwrap()), want); + // Control: sRGB still gets its own transform after the colliding lookup. + assert_eq!(eval(&cached(srgb, lab, opts).unwrap()), sr); + } +} + +#[cfg(test)] +mod curve_budget_tests { + use super::*; + + #[test] + fn aliased_curves_are_charged_for_each_decoded_table() { + // One 1M-sample `curv` element referenced seventeen times decodes 17M samples. + let mut element = b"curv\0\0\0\0".to_vec(); + element.extend((1u32 << 20).to_be_bytes()); + element.extend(std::iter::repeat_n(0u8, 2 << 20)); + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); + let mut failed_at = None; + for i in 0..17 { + if parse_curve(&element).is_err() { + failed_at = Some(i); + break; + } + } + assert_eq!(failed_at, Some(16), "sixteen tables fit the budget, the seventeenth does not"); + // Each profile parse starts with a fresh budget. + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); + assert!(parse_curve(&element).is_ok()); + } } diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index f30c17c82e..3ce59687a7 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -703,20 +703,33 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result>, + dst: Arc>, + transform: Arc, +} + +/// Process-wide cache of transforms keyed by profile content and options. The key is the +/// profiles' 64-bit hashes, which a crafted profile can share with another (#8613), so a hit +/// counts only when the bytes match too; a colliding pair gets its own, uncached transform. pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result, CmsError> { - static CACHE: OnceLock>>> = OnceLock::new(); + static CACHE: OnceLock>> = OnceLock::new(); let key = (src.content_hash(), dst.content_hash(), opts); + let (src_bytes, dst_bytes) = (src.to_bytes(), dst.to_bytes()); let cache = CACHE.get_or_init(Default::default); - if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { - return Ok(t.clone()); + let hit = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key).map(|e| (e.src == src_bytes && e.dst == dst_bytes).then(|| e.transform.clone())); + match hit { + Some(Some(t)) => return Ok(t), + Some(None) => return Ok(Arc::new(Transform::with_options(src, dst, opts)?)), + None => {} } let t = Arc::new(Transform::with_options(src, dst, opts)?); let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); if c.len() > 64 { c.clear(); } - c.insert(key, t.clone()); + c.insert(key, Entry { src: src_bytes, dst: dst_bytes, transform: t.clone() }); Ok(t) } diff --git a/crates/cms/tests/bench.rs b/crates/cms/tests/it/bench.rs similarity index 95% rename from crates/cms/tests/bench.rs rename to crates/cms/tests/it/bench.rs index e0b9628a53..06789ff350 100644 --- a/crates/cms/tests/bench.rs +++ b/crates/cms/tests/it/bench.rs @@ -1,4 +1,4 @@ -//! Throughput check: `cargo test -p photocraft-cms --release --test bench -- --ignored --nocapture`. +//! Throughput check: `cargo test -p photocraft-cms --release --test it -- bench:: --ignored --nocapture`. use photocraft_cms::{Builtin, Intent, Transform}; diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/it/cms.rs similarity index 100% rename from crates/cms/tests/cms.rs rename to crates/cms/tests/it/cms.rs diff --git a/crates/cms/tests/gpu_matrix_shaper.rs b/crates/cms/tests/it/gpu_matrix_shaper.rs similarity index 100% rename from crates/cms/tests/gpu_matrix_shaper.rs rename to crates/cms/tests/it/gpu_matrix_shaper.rs diff --git a/crates/cms/tests/it/main.rs b/crates/cms/tests/it/main.rs new file mode 100644 index 0000000000..127f08435b --- /dev/null +++ b/crates/cms/tests/it/main.rs @@ -0,0 +1,6 @@ +//! The crate's integration tests, linked into one test binary. + +mod bench; +mod cms; +mod gpu_matrix_shaper; +mod regen; diff --git a/crates/cms/tests/regen.rs b/crates/cms/tests/it/regen.rs similarity index 98% rename from crates/cms/tests/regen.rs rename to crates/cms/tests/it/regen.rs index 54db9b4e2e..05d4b696b3 100644 --- a/crates/cms/tests/regen.rs +++ b/crates/cms/tests/it/regen.rs @@ -1,7 +1,7 @@ //! Regenerates the shipped synthetic CMYK profile when `PHOTOCRAFT_REGEN_PROFILES=1`: //! //! ```sh -//! PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test regen +//! PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test it -- regen:: //! ``` //! //! Without the variable the test only checks that the generator's model still matches the diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 25f3f09be0..062f7c4f98 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -31,6 +31,8 @@ image-webp = "0.2" zune-jpeg = "0.5" zune-core = "0.5" jpeg-encoder = "0.6" +# 12-bit and arithmetic-coded JPEG, which zune-jpeg does not decode. +craft-jpeg.workspace = true photocraft-heif = { path = "../heif", optional = true } # Orientation is applied a band of rows per task on native; wasm stays single-threaded. diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 46a33e89b3..c191cc40b2 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -106,6 +106,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. + `FidelityWarning::BitsReduced` reports a 10- or 12-bit save (and every Cineon save) that drops + precision: always for float samples, for U16 ones unless they are already code values at that + depth. Other descriptors, signed or run-length-encoded data and other bit depths return `CodecError::Unsupported` naming the field. diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index f93c79c928..aa71f94651 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -537,6 +537,26 @@ pub(crate) fn bits_for(opt: Option, sample: SampleType) -> u8 { } } +/// The bits `format` stores below its U16 plan: DPX at 10 or 12 bits, Cineon's 10. `None` when +/// the stored depth is the plan's sample type. +pub(crate) fn packed_bits(format: Format, opts: &EncodeOptions) -> Option { + match format { + Format::Dpx => opts.dpx_bits.filter(|b| matches!(b, DpxBits::Bits10 | DpxBits::Bits12)).map(DpxBits::bits), + Format::Cineon => Some(10), + _ => None, + } +} + +/// `true` when every sample of a U16 image is already a `bits`-bit code value, so writing it at +/// `bits` and reading it back gives the same samples (a frame opened from such a file). +pub(crate) fn keeps_u16_samples(img: &Image, bits: u8) -> bool { + if img.sample_type() != SampleType::U16 || !(1..16).contains(&bits) { + return false; + } + let max = (1u64 << bits) - 1; + img.data().as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).all(|v| widen(narrow(v, max), max) == v) +} + /// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16. pub(crate) fn plan_sample(opt: Option, src: SampleType) -> SampleType { match opt { diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index cf1c9fca43..3e533ccd08 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -1,4 +1,5 @@ -//! JPEG: decode via `zune-jpeg` (gray, YCbCr, CMYK, YCCK), encode via +//! JPEG: decode via `zune-jpeg` (gray, YCbCr, CMYK, YCCK; arithmetic-coded and 12-bit files +//! via [`craft_jpeg`]), encode via //! `jpeg-encoder` (gray, RGB, CMYK). Metadata (APP0 JFIF density, APP1 //! EXIF/XMP, APP2 multi-segment ICC, APP13 Photoshop resources, APP14 Adobe) is //! parsed by our own marker scanner so behaviour does not depend on decoder internals. @@ -137,32 +138,39 @@ enum DataEnd { /// Walks the marker segments (skipped by their length) and the entropy-coded scan data /// (skipped up to the next marker that isn't a stuffed `FF 00`, a restart or a fill byte), /// so an embedded thumbnail's end marker or data after the end of the image don't count. -fn data_end(b: &[u8]) -> DataEnd { +/// Also returns how many scan-data bytes the walk crossed: the truncated-input budget is +/// measured on these, not on the file length, which APP padding can inflate without +/// adding image data. +fn data_end(b: &[u8]) -> (DataEnd, u64) { // Where the bytes run out: inside the image data once a scan has held some. - let cut = |scan_data: bool| if scan_data { DataEnd::Truncated } else { DataEnd::Empty }; + let cut = |scan_data: bool, scanned: u64| (if scan_data { DataEnd::Truncated } else { DataEnd::Empty }, scanned); let mut i = 2; // past SOI let mut scan_data = false; + let mut scanned = 0u64; loop { - let Some(&byte) = b.get(i) else { return cut(scan_data) }; + let Some(&byte) = b.get(i) else { return cut(scan_data, scanned) }; if byte != 0xFF { // Not where a marker should be: too odd to judge. - return DataEnd::Complete; + return (DataEnd::Complete, scanned); } - let Some(&code) = b.get(i + 1) else { return cut(scan_data) }; + let Some(&code) = b.get(i + 1) else { return cut(scan_data, scanned) }; match code { - 0xD9 => return DataEnd::Complete, + 0xD9 => return (DataEnd::Complete, scanned), 0xFF => i += 1, 0x01 | 0xD0..=0xD8 => i += 2, _ => { let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else { - return cut(scan_data); + return cut(scan_data, scanned); }; i = i.saturating_add(2 + usize::from(len)); if code == 0xDA { let start = i; loop { let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else { - return cut(scan_data || b.len() > start); + // EOF inside this scan: everything from the scan start to the end + // of the file is scan data (inside a declared segment it may not + // be, so only count what remains). + return cut(scan_data || b.len() > start, scanned + u64::try_from(b.len().saturating_sub(start)).unwrap_or(0)); }; i += p; match b.get(i + 1) { @@ -172,6 +180,7 @@ fn data_end(b: &[u8]) -> DataEnd { } } scan_data |= i > start; + scanned += u64::try_from(i - start).unwrap_or(0); } } } @@ -182,8 +191,53 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } +/// Whether `craft-jpeg` (rather than `zune-jpeg`) decodes the file: arithmetic coding +/// (SOF9–SOF11) or a DCT frame whose precision isn't 8 bits (#4934, #5003). Lossless (SOF3) and +/// 12-bit progressive Huffman (SOF2) go there too: craft-jpeg reports them unsupported, where +/// zune-jpeg's failure would read as a damaged file. +fn uses_craft_jpeg(bytes: &[u8]) -> bool { + match craft_jpeg::frame(bytes) { + Some(f) if (0xC9..=0xCB).contains(&f.marker) || f.marker == 0xC3 => true, + Some(f) if matches!(f.marker, 0xC0..=0xC2) => f.precision != 8, + _ => false, + } +} + +fn craft_jpeg_error(e: craft_jpeg::Error) -> CodecError { + match e { + craft_jpeg::Error::Malformed(m) => err(m), + craft_jpeg::Error::Unsupported(m) => CodecError::unsupported(F, m), + craft_jpeg::Error::LimitExceeded(m) => CodecError::LimitExceeded(m), + e => err(e), + } +} + +/// Decode a file [`uses_craft_jpeg`] routes away from `zune-jpeg`; `damaged` says entropy-coded +/// data was cut short or corrupt. +fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option) -> Result<(Image, bool), CodecError> { + let mut lim = craft_jpeg::Limits::default(); + lim.max_pixels = limits.max_pixels; + lim.max_alloc = limits.max_alloc; + let d = craft_jpeg::decode(bytes, &lim, adobe_transform).map_err(craft_jpeg_error)?; + let layout = match d.components { + 1 => ChannelLayout::Gray, + 3 => ChannelLayout::Rgb, + 4 => ChannelLayout::Cmyk, + n => return Err(err(format!("{n} components"))), + }; + let img = match d.samples { + craft_jpeg::Samples::U8(px) => Image::from_raw(d.width, d.height, layout, SampleType::U8, px)?, + craft_jpeg::Samples::U16(px) => Image::from_u16(d.width, d.height, layout, &px)?, + }; + Ok((img, d.damaged)) +} + +/// Most pixels one byte of truncated scan data may account for. Even a flat image costs a few +/// bits per 8×8 block, and truncated photographs are far above this. +const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048; + pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { - let end = data_end(bytes); + let (end, scan_bytes) = data_end(bytes); if end == DataEnd::Empty { return Err(err("the file ends before any image data")); } @@ -191,6 +245,25 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result if let Some((w, h, nc)) = meta.frame { limits.check_bytes(w, h, u64::from(nc.max(1)))?; } + if uses_craft_jpeg(bytes) { + // craft-jpeg sizes its buffers from the frame header before it reads a scan. + if scan_bytes == 0 { + return Err(err("the file has no scan data")); + } + if let Some((w, h, _)) = meta.frame + && end == DataEnd::Truncated + && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes + { + return Err(err("the truncated scan is too short for the frame it declares")); + } + let (mut img, damaged) = decode_craft_jpeg(bytes, limits, meta.adobe_transform)?; + img.icc = meta.icc; + img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() }; + if end == DataEnd::Truncated || damaged { + img.warnings.push(DecodeWarning::Truncated { format: F }); + } + return Ok(img); + } let options = DecoderOptions::default().set_strict_mode(false).set_max_width(65535).set_max_height(65535); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options); dec.decode_headers().map_err(err)?; @@ -204,6 +277,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result _ => (ColorSpace::RGB, ChannelLayout::Rgb), }; limits.check(w, h, layout, SampleType::U8)?; + // The backend fills whatever the scan no longer describes, so a short scan under a large + // frame is a gigabyte of synthesized output from a few kilobytes of file. The budget is + // measured on the scan data itself: APP padding can inflate `bytes.len()` arbitrarily + // without describing one more pixel. + if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes { + return Err(err("the truncated scan is too short for the frame it declares")); + } dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs)); let mut px = dec.decode().map_err(err)?; let expected = w as usize * h as usize * layout.channels(); @@ -435,6 +515,52 @@ mod tests { bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0") } + /// A tiny truncated scan under a frame patched to 20000×20000 is refused before the backend + /// fills 400 million pixels from it; the same file with its real size still recovers. + #[test] + fn a_short_truncated_scan_cannot_declare_a_huge_frame() { + let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap(); + let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let cut = &full[..full.len() - 4]; + assert_eq!(data_end(cut).0, DataEnd::Truncated); + assert!(crate::decode_as(Format::Jpeg, cut).is_ok()); + let sof = cut.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap(); + let mut huge = cut.to_vec(); + huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]); + let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err(); + assert!(e.to_string().contains("too short"), "{e}"); + } + + /// The truncated-scan budget counts scan data, not the file length: APP padding before the + /// window cannot admit an oversized output. The same padded file with its real size still + /// decodes (with the truncation warning). + #[test] + fn app_padding_cannot_admit_an_oversized_truncated_frame() { + let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap(); + let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let cut = &full[..full.len() - 4]; + // Four full-length COMMENT segments (~256 KiB) stand where APP padding would; they are + // complete, so the file otherwise parses normally. + let fill = [b'p'; 65533]; + let mut padded = cut[..2].to_vec(); + for _ in 0..4 { + padded.extend_from_slice(&[0xFF, 0xFE]); + padded.extend_from_slice(&0xFFFFu16.to_be_bytes()); + padded.extend_from_slice(&fill); + } + padded.extend_from_slice(&cut[2..]); + assert_eq!(data_end(&padded).0, DataEnd::Truncated); + // Well above the frame's pixels / budget, yet far above the scan data: the old + // file-length bound admitted this frame. + assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small"); + assert!(crate::decode_as(Format::Jpeg, &padded).is_ok()); + let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap(); + let mut huge = padded.clone(); + huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]); + let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err(); + assert!(e.to_string().contains("too short"), "{e}"); + } + /// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker. #[test] fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() { @@ -488,4 +614,178 @@ mod tests { assert!(has_jfif_app0(&bytes)); assert_eq!(scan_metadata(&bytes).dpi, Some((96.0, 96.0))); } + + // --- Arithmetic-coded and 12-bit files, decoded by craft-jpeg --- + + fn segment(out: &mut Vec, marker: u8, body: &[u8]) { + out.extend_from_slice(&[0xFF, marker]); + out.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(body); + } + + /// One 8×8 gray block, quantizer 1, with entropy-coded scan data `data`. + fn gray_block(sof: u8, precision: u8, dht: Option<&[u8]>, data: &[u8]) -> Vec { + let mut s = vec![0xFF, 0xD8]; + let mut dqt = vec![0u8]; + dqt.extend([1u8; 64]); + segment(&mut s, 0xDB, &dqt); + segment(&mut s, sof, &[precision, 0, 8, 0, 8, 1, 1, 0x11, 0]); + if let Some(dht) = dht { + segment(&mut s, 0xC4, dht); + } + segment(&mut s, 0xDA, &[1, 1, 0x00, 0, 63, 0]); + s.extend_from_slice(data); + s.extend([0xFF, 0xD9]); + s + } + + /// SOF9 with only a DC coefficient (from a T.81 arithmetic encoder): 8-bit DC 200 → sample + /// 200 / 8 + 128 = 153; 12-bit DC 1000 → 1000 / 8 + 2048 = 2173. + fn arithmetic_block(precision: u8) -> Vec { + let data: &[u8] = if precision == 8 { &[210, 217, 68, 0] } else { &[210, 234, 134, 0] }; + gray_block(0xC9, precision, None, data) + } + + /// A 12-bit SOF1 stream: one DC coefficient. The DC table has one code (`0`, category 13), + /// the AC table one (`0`, end of block). DC = 7616 → sample 7616 / 8 + 2048 = 3000. + fn twelve_bit_huffman_block() -> Vec { + let mut dht = vec![0x00, 1]; + dht.extend([0u8; 15]); + dht.push(13); + dht.extend([0x10, 1]); + dht.extend([0u8; 15]); + dht.push(0x00); + // Bits: `0`, 7616 in 13 bits, `0`, one pad `1`. + let word: u16 = (7616 << 2) | 1; + gray_block(0xC1, 12, Some(&dht), &word.to_be_bytes()) + } + + fn twelve_bit(v: f32) -> u16 { + (v * 65535.0 / 4095.0).round() as u16 + } + + #[test] + fn routing() { + assert!(!uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(!uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC9, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(!uses_craft_jpeg(&[0xFF, 0xD8])); + assert!(!uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC2, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC2, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC3, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); + } + + #[test] + fn lossless_and_twelve_bit_progressive_are_unsupported_not_damaged() { + for marker in [0xC3, 0xC2] { + let mut bytes = vec![0xFF, 0xD8, 0xFF, marker, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0]; + bytes.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 0x3F, 0, 0x12, 0x34, 0xFF, 0xD9]); + let e = crate::decode(&bytes).unwrap_err(); + assert!(matches!(e, CodecError::Unsupported { .. }), "{marker:#x}: {e:?}"); + } + } + + #[test] + fn a_twelve_bit_frame_without_scan_data_is_refused_before_decoding() { + let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0x40, 0, 0x40, 0, 1, 1, 0x11, 0]; + bytes.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 0x3F, 0, 0xFF, 0xD9]); + let e = crate::decode(&bytes).unwrap_err(); + assert!(e.to_string().contains("no scan data"), "{e}"); + } + + #[test] + fn arithmetic_eight_bit_block_is_eight_bit() { + let img = crate::decode(&arithmetic_block(8)).unwrap(); + assert_eq!((img.dimensions(), img.sample_type(), img.layout()), ((8, 8), SampleType::U8, ChannelLayout::Gray)); + assert!(img.data().iter().all(|&v| v.abs_diff(153) <= 1), "{:?}", &img.data()[..4]); + assert!(img.warnings.is_empty()); + } + + #[test] + fn arithmetic_twelve_bit_block_is_sixteen_bit() { + let img = crate::decode(&arithmetic_block(12)).unwrap(); + assert_eq!((img.dimensions(), img.sample_type(), img.layout()), ((8, 8), SampleType::U16, ChannelLayout::Gray)); + let want = twelve_bit(2173.0); + assert!(img.to_u16_samples().unwrap().iter().all(|&v| v.abs_diff(want) <= 1)); + } + + #[test] + fn twelve_bit_huffman_block_decodes_to_sixteen_bit() { + let img = crate::decode(&twelve_bit_huffman_block()).unwrap(); + assert_eq!((img.dimensions(), img.sample_type()), ((8, 8), SampleType::U16)); + let want = twelve_bit(3000.0); + let px = img.to_u16_samples().unwrap(); + assert!(px.iter().all(|&v| v.abs_diff(want) <= 1), "{:?} vs {want}", &px[..4]); + assert!(img.warnings.is_empty()); + } + + #[test] + fn twelve_bit_jpeg_keeps_its_photoshop_resources() { + // An APP13 `8BIM` block (paths, Photoshop settings) right after SOI, as on the zune-jpeg path. + let resources = b"8BIM\x04\x04\0\0\0\0\0\0".to_vec(); + let mut app13 = PHOTOSHOP_HEADER.to_vec(); + app13.extend_from_slice(&resources); + let stream = twelve_bit_huffman_block(); + let mut with = stream[..2].to_vec(); + segment(&mut with, 0xED, &app13); + with.extend_from_slice(&stream[2..]); + assert!(uses_craft_jpeg(&with)); + let img = crate::decode(&with).unwrap(); + assert_eq!(img.meta.photoshop_resources.as_deref(), Some(resources.as_slice())); + } + + /// Where the entropy-coded data of the last scan starts. + fn last_scan_data(s: &[u8]) -> usize { + let sos = s.windows(2).rposition(|p| p == [0xFF, 0xDA]).unwrap(); + sos + 2 + usize::from(u16::from_be_bytes([s[sos + 2], s[sos + 3]])) + } + + /// A 12-bit scan whose entropy data runs out, EOI kept, is never a clean image. + #[test] + fn twelve_bit_scan_out_of_data_is_not_clean() { + let full = twelve_bit_huffman_block(); + let data = last_scan_data(&full); + for keep in 0..2 { + let mut s = full[..data + keep].to_vec(); + s.extend([0xFF, 0xD9]); + if let Ok(img) = crate::decode(&s) { + assert!(!img.warnings.is_empty(), "keep {keep}: clean decode of missing data"); + } + } + } + + /// A multi-MCU SOF1 12-bit scan cut in half, EOI kept, decodes with the Truncated warning; a + /// truncated scan too short for its frame is refused, as on the zune-jpeg path. + #[test] + fn damaged_or_truncated_twelve_bit_scan() { + let (w, h) = (64usize, 64usize); + let rgb: Vec = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 4) as u8, (i * 7) as u8]).collect(); + let mut out = Vec::new(); + jpeg_encoder::Encoder::new(&mut out, 90).encode(&rgb, w as u16, h as u16, jpeg_encoder::ColorType::Rgb).unwrap(); + // Relabel the baseline frame as 12-bit SOF1: the Huffman data still parses, as different values. + let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap(); + out[sof + 1] = 0xC1; + out[sof + 4] = 12; + let data = last_scan_data(&out); + let end = out.len() - 2; + let mut cut = out[..data + (end - data) / 2].to_vec(); + if cut.last() == Some(&0xFF) { + cut.pop(); + } + cut.extend([0xFF, 0xD9]); + let img = crate::decode(&out).unwrap(); + assert_eq!(img.sample_type(), SampleType::U16); + assert!(img.warnings.is_empty(), "{:?}", img.warnings); + let img = crate::decode(&cut).unwrap(); + assert_eq!(img.warnings, vec![DecodeWarning::Truncated { format: Format::Jpeg }]); + // Without EOI the same cut is a truncated file; under a 20000×20000 frame it is refused. + cut.truncate(cut.len() - 2); + assert_eq!(data_end(&cut).0, DataEnd::Truncated); + assert!(crate::decode(&cut).is_ok()); + cut[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]); + let e = crate::decode(&cut).unwrap_err(); + assert!(e.to_string().contains("too short for the frame it declares"), "{e}"); + } } diff --git a/crates/codecs/src/codecs/pcx.rs b/crates/codecs/src/codecs/pcx.rs index f89a781e9d..48dbf1169e 100644 --- a/crates/codecs/src/codecs/pcx.rs +++ b/crates/codecs/src/codecs/pcx.rs @@ -72,8 +72,31 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { return Err(bad("8-bit indexed PCX has no 256-colour palette")); } let image_end = if vga { b.len() - 769 } else { b.len() }; + // A 63-byte RLE packet takes 2 bytes; a trailing remainder of 1 byte below 0xC0 + // can be encoded as a 1-byte literal, while any larger remainder takes at least 2 bytes. + // Because RLE packets may span planes within a scanline (`row_len`), compute the + // minimum encoded bytes across the full scanline (`row_len`) and multiply by `h`. + // Reject inputs that cannot possibly contain all scanlines before allocating + // the decoded image buffer. + let min_packets = (row_len / 63) + .checked_mul(2) + .and_then(|n| { + n.checked_add(match row_len % 63 { + 0 => 0, + 1 => 1, + _ => 2, + }) + }) + .ok_or_else(|| bad("compressed row size overflow"))?; + let min_encoded = min_packets.checked_mul(h as usize).ok_or_else(|| bad("compressed image size overflow"))?; + let encoded_len = image_end.saturating_sub(HEADER); + if encoded_len < min_encoded { + return Err(bad("PCX pixel data is too short for the declared image")); + } let mut row = vec![0u8; row_len]; - let mut pixels = vec![0u8; total]; + let mut pixels = Vec::new(); + pixels.try_reserve_exact(total).map_err(|_| bad("PCX pixel buffer is too large"))?; + pixels.resize(total, 0); let mut p = HEADER; let width = w as usize; for y in 0..h as usize { diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index f109f62e96..3f69d3c9cd 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -85,7 +85,9 @@ pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result= MAX_GIF_FRAMES { return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames"))); } - if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc { + // Every frame keeps its own copy of the file's ICC profile along with its pixels. + let frame_bytes = (size as u64).saturating_add(icc.as_ref().map_or(0, |p| p.len() as u64)); + if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc { return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc))); } let delay_ms = match dec.read_frame(&mut buf) { @@ -126,9 +128,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Result, CodecError None }; let icc = if opts.embed_icc { img.icc.as_deref() } else { None }; - let exif = if opts.embed_metadata { img.meta.exif.as_deref().map(|e| crate::resolution::export_exif(e, img.meta.dpi)) } else { None }; + let exif = if opts.embed_metadata { export_exif(img) } else { None }; let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None }; Ok(riff(w, h, &frame, alpha.as_deref(), icc, exif.as_deref(), xmp.as_deref().map(str::as_bytes))) } +/// The EXIF to write: the image's, upright (the pixels are written as they are shown, so a viewer +/// must never rotate them again), carrying the resolution. WebP has no resolution field of its own, +/// so the resolution goes into EXIF, added when the image has none (#2399). +pub(crate) fn export_exif(img: &Image) -> Option> { + let exif = img.meta.exif.as_deref().unwrap_or_default(); + // A WebP without EXIF already reads as 72 ppi, so the default needs no block of its own (and + // the extended container it would bring to every web export). + if exif.is_empty() && img.meta.dpi.is_none_or(is_default_ppi) { + return None; + } + let out = crate::resolution::export_exif_adding_missing_resolution(exif, img.meta.dpi).into_owned(); + (!out.is_empty()).then_some(out) +} + +/// Whether a WebP of `img` keeps its resolution: the EXIF written carries it. Not when the image's +/// own EXIF can't be parsed: that block is written as it is rather than replaced, so the +/// resolution is dropped (and reported) instead of the metadata. +pub(crate) fn keeps_dpi(img: &Image) -> bool { + let bare_default = img.meta.exif.as_deref().is_none_or(<[u8]>::is_empty) && img.meta.dpi.is_none_or(is_default_ppi); + // The XMP written beside it carries the resolution too, and readers look there first. + let in_xmp = img.meta.xmp.as_deref().is_some_and(|x| crate::resolution::xmp_resolution(&crate::resolution::export_xmp(x, img.meta.dpi)).is_some()); + bare_default || in_xmp || export_exif(img).is_some_and(|e| crate::resolution::exif_resolution(&e).is_some()) +} + +/// 72 ppi each way: what a file without a resolution opens as. +fn is_default_ppi((x, y): (f32, f32)) -> bool { + (x - 72.0).abs() < 0.01 && (y - 72.0).abs() < 0.01 +} + /// A RIFF chunk: FourCC, little-endian size, payload, padded to an even length. fn chunk(out: &mut Vec, fourcc: &[u8; 4], payload: &[u8]) { out.extend_from_slice(fourcc); diff --git a/crates/codecs/src/codecs/xpm.rs b/crates/codecs/src/codecs/xpm.rs index 82543d8867..c20f3c58e9 100644 --- a/crates/codecs/src/codecs/xpm.rs +++ b/crates/codecs/src/codecs/xpm.rs @@ -206,11 +206,16 @@ fn decode_lines(lines: &[Vec], limits: &Limits) -> Result } } let row_len = width.checked_mul(cpp).ok_or_else(|| bad("row byte count overflow"))?; - let mut pixels = vec![0u8; width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?]; + // Validate all source rows before reserving the decoded RGBA image. A malformed + // short row must not be able to make a large header-sized allocation first. + if lines[1 + colours..required].iter().any(|line| line.len() != row_len) { + return Err(bad("XPM row has the wrong width")); + } + let total = width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?; + let mut pixels = Vec::new(); + pixels.try_reserve_exact(total).map_err(|_| bad("XPM pixel buffer is too large"))?; + pixels.resize(total, 0); for (y, line) in lines[1 + colours..required].iter().enumerate() { - if line.len() != row_len { - return Err(bad("XPM row has the wrong width")); - } for x in 0..width { let key = &line[x * cpp..(x + 1) * cpp]; let rgba = palette.get(key).ok_or_else(|| bad("unknown palette key"))?; diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index a6937b571c..c9bce9671e 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -4,6 +4,7 @@ use std::fmt; +use crate::codecs::dpx; use crate::format::{Format, caps}; use crate::image::{ChannelLayout, Image, SampleType}; use crate::options::{DdsCompression, EncodeOptions}; @@ -20,6 +21,12 @@ pub enum FidelityWarning { from: SampleType, to: SampleType, }, + /// Samples will be stored with fewer bits than their sample type holds: DPX at 10 or 12 + /// bits, Cineon at 10. + BitsReduced { + from: SampleType, + bits: u8, + }, /// Float samples outside `[0, 1]` will be clipped by integer storage. RangeClipped, /// Non-opaque alpha will be discarded. @@ -82,6 +89,7 @@ impl fmt::Display for FidelityWarning { write!(f, "{} cannot be written in this build", format.name()) } DepthReduced { from, to } => write!(f, "{} will be reduced to {}", depth_name(*from), depth_name(*to)), + BitsReduced { from, bits } => write!(f, "{} will be reduced to {bits}-bit", depth_name(*from)), RangeClipped => write!(f, "HDR values outside 0..1 will be clipped"), AlphaDiscarded => write!(f, "alpha will be discarded"), AlphaBinarized => write!(f, "alpha will be reduced to on/off transparency"), @@ -161,7 +169,7 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan // The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16). Format::Dpx => Plan { layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) }, - sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample), + sample: dpx::plan_sample(opts.dpx_bits, sample), }, // CIELab has no float form: float Lab is written with 16 bits. Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 }, @@ -201,7 +209,13 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption (SampleType::F16, SampleType::F32) => false, _ => true, }; - if lossy_depth { + // DPX at 10 or 12 bits and Cineon keep fewer bits than their U16 plan: float samples always + // lose precision there, 16-bit ones unless they are already code values at that depth. + if let Some(bits) = dpx::packed_bits(format, opts).filter(|_| ss != SampleType::U8) { + if ss.is_float() || !dpx::keeps_u16_samples(&image.converted(p.layout, SampleType::U16), bits) { + w.push(W::BitsReduced { from: ss, bits }); + } + } else if lossy_depth { w.push(W::DepthReduced { from: ss, to: p.sample }); } // FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too. @@ -254,7 +268,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() }); } } - if m.dpi.is_some() && !c.dpi { + if m.dpi.is_some() && (!c.dpi || (format == Format::WebP && !crate::codecs::webp::keeps_dpi(image))) { w.push(W::DpiDropped); } // DPX keeps its own header fields, not free-form text. diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index a602da57b7..a333106ab2 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -256,7 +256,8 @@ pub fn caps(format: Format) -> FormatCaps { } Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base }, Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base }, - Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base }, + // Resolution travels in EXIF (WebP has no field of its own). + Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, dpi: true, animation: true, ..base }, Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base }, Format::Bmp => base, Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base }, diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 1f7b249722..2279504413 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -14,7 +14,9 @@ pub struct Limits { /// Maximum bytes for the decoded pixel buffer (and passed on as the /// allocation budget to underlying decoders). /// PNG also applies this to the aggregate decoded text/XMP UTF-8 bytes, - /// including keywords, separately from pixels. This is not a total RSS cap. + /// including keywords, separately from pixels. Animated WebP charges + /// every retained frame's full pixel copy plus its own copy of the + /// file's ICC profile against this budget. This is not a total RSS cap. pub max_alloc: u64, } diff --git a/crates/codecs/tests/camera_raw.rs b/crates/codecs/tests/it/camera_raw.rs similarity index 100% rename from crates/codecs/tests/camera_raw.rs rename to crates/codecs/tests/it/camera_raw.rs diff --git a/crates/codecs/tests/common/mod.rs b/crates/codecs/tests/it/common/mod.rs similarity index 100% rename from crates/codecs/tests/common/mod.rs rename to crates/codecs/tests/it/common/mod.rs diff --git a/crates/codecs/tests/common/tiffgen.rs b/crates/codecs/tests/it/common/tiffgen.rs similarity index 100% rename from crates/codecs/tests/common/tiffgen.rs rename to crates/codecs/tests/it/common/tiffgen.rs diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/it/dds.rs similarity index 100% rename from crates/codecs/tests/dds.rs rename to crates/codecs/tests/it/dds.rs diff --git a/crates/codecs/tests/dds_write.rs b/crates/codecs/tests/it/dds_write.rs similarity index 99% rename from crates/codecs/tests/dds_write.rs rename to crates/codecs/tests/it/dds_write.rs index 119101e4fd..e51e21ceda 100644 --- a/crates/codecs/tests/dds_write.rs +++ b/crates/codecs/tests/it/dds_write.rs @@ -348,7 +348,7 @@ fn compression_names_round_trip() { /// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release: /// /// ```sh -/// cargo test --release -p photocraft-codecs --test dds_write -- --ignored --nocapture +/// cargo test --release -p photocraft-codecs --test it -- dds_write:: --ignored --nocapture /// ``` #[test] #[ignore] diff --git a/crates/codecs/tests/decode_warnings.rs b/crates/codecs/tests/it/decode_warnings.rs similarity index 99% rename from crates/codecs/tests/decode_warnings.rs rename to crates/codecs/tests/it/decode_warnings.rs index 5fc310104a..82bf75b57d 100644 --- a/crates/codecs/tests/decode_warnings.rs +++ b/crates/codecs/tests/it/decode_warnings.rs @@ -1,7 +1,7 @@ //! Decode warnings: a file that holds more than was decoded (#523: animations, multi-page //! TIFF) or less than it should (#518: truncated JPEG) says so; complete single images don't. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/it/deep_exr.rs similarity index 100% rename from crates/codecs/tests/deep_exr.rs rename to crates/codecs/tests/it/deep_exr.rs diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/it/detect_caps.rs similarity index 99% rename from crates/codecs/tests/detect_caps.rs rename to crates/codecs/tests/it/detect_caps.rs index 2f55d3cf58..8db608de12 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/it/detect_caps.rs @@ -1,7 +1,7 @@ //! Magic-number detection, extensions, and the capability table //! (including the symmetric read/write guarantee). -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/dpx.rs b/crates/codecs/tests/it/dpx.rs similarity index 87% rename from crates/codecs/tests/dpx.rs rename to crates/codecs/tests/it/dpx.rs index 6fbfb2db7b..3ec8978247 100644 --- a/crates/codecs/tests/dpx.rs +++ b/crates/codecs/tests/it/dpx.rs @@ -323,6 +323,49 @@ fn chosen_bit_depth_sets_the_header_and_packing() { assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 })); } +/// The depth warnings of `img` written as `format` with `o`. +fn depth_warnings(img: &Image, format: Format, o: &EncodeOptions) -> Vec { + fidelity_warnings_with(img, format, o) + .into_iter() + .filter(|w| matches!(w, FidelityWarning::DepthReduced { .. } | FidelityWarning::BitsReduced { .. })) + .collect() +} + +#[test] +fn saving_sixteen_bits_at_ten_or_twelve_warns_with_the_stored_bits() { + let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::>()).unwrap(); + for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] { + assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits }]); + assert_eq!(FidelityWarning::BitsReduced { from: SampleType::U16, bits }.to_string(), format!("16-bit will be reduced to {bits}-bit")); + // Float samples name the stored bits too, not the 16-bit words they are packed in. + let float = img.convert(ChannelLayout::Rgb, SampleType::F32); + assert_eq!(depth_warnings(&float, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::F32, bits }]); + // 8-bit samples widen into 10 and 12 bits without loss. + let eight = img.convert(ChannelLayout::Rgb, SampleType::U8); + assert_eq!(depth_warnings(&eight, Format::Dpx, &opts(Some(choice))), []); + } + assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits16))), []); + assert_eq!(depth_warnings(&img, Format::Dpx, &opts(None)), []); + assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits8))), [FidelityWarning::DepthReduced { from: SampleType::U16, to: SampleType::U8 }]); + assert_eq!(depth_warnings(&img, Format::Cineon, &EncodeOptions::default()), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]); +} + +#[test] +fn a_frame_opened_at_ten_or_twelve_bits_saves_back_without_a_warning() { + let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::>()).unwrap(); + for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] { + let opened = decode(&encode(&img, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap(); + assert_eq!(depth_warnings(&opened, Format::Dpx, &opts(Some(choice))), [], "{bits}-bit"); + assert_eq!(decode(&encode(&opened, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap(), opened, "{bits}-bit"); + } + // A 12-bit frame saved at 10 bits does lose its two low bits. + let twelve = decode(&encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits12))).unwrap()).unwrap(); + assert_eq!(depth_warnings(&twelve, Format::Dpx, &opts(Some(DpxBits::Bits10))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]); + let cineon = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap(); + assert_eq!(depth_warnings(&cineon, Format::Cineon, &EncodeOptions::default()), []); + assert_eq!(depth_warnings(&cineon, Format::Dpx, &opts(Some(DpxBits::Bits10))), []); +} + #[test] fn gray_with_alpha_is_written_as_luma_with_a_warning() { let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap(); diff --git a/crates/codecs/tests/exr_cryptomatte.rs b/crates/codecs/tests/it/exr_cryptomatte.rs similarity index 100% rename from crates/codecs/tests/exr_cryptomatte.rs rename to crates/codecs/tests/it/exr_cryptomatte.rs diff --git a/crates/codecs/tests/exr_multipart.rs b/crates/codecs/tests/it/exr_multipart.rs similarity index 100% rename from crates/codecs/tests/exr_multipart.rs rename to crates/codecs/tests/it/exr_multipart.rs diff --git a/crates/codecs/tests/fidelity.rs b/crates/codecs/tests/it/fidelity.rs similarity index 90% rename from crates/codecs/tests/fidelity.rs rename to crates/codecs/tests/it/fidelity.rs index 93b0236fdf..fa77a8d47a 100644 --- a/crates/codecs/tests/fidelity.rs +++ b/crates/codecs/tests/it/fidelity.rs @@ -1,6 +1,6 @@ //! `fidelity_warnings` cases, and consistency with what `encode` does. -mod common; +use crate::common; use FidelityWarning as W; use common::*; use photocraft_codecs::*; @@ -99,13 +99,14 @@ fn exif_xmp_dpi_text_dropped_for_qoi() { assert!(w.contains(&x), "{x:?}"); } } +/// WebP keeps the resolution in EXIF (#2399); only free-form text has nowhere to go. #[test] -fn webp_drops_dpi_and_text_only() { +fn webp_drops_text_only() { let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() }; let w = warns(&img, Format::WebP); - assert_eq!(w, vec![W::DpiDropped, W::TextDropped]); + assert_eq!(w, vec![W::TextDropped]); } #[test] fn float_to_png_reduces_and_clips() { @@ -180,6 +181,7 @@ fn every_warning_has_a_message() { let all = [ W::WriteUnsupported { format: Format::Avif }, W::DepthReduced { from: SampleType::F32, to: SampleType::F16 }, + W::BitsReduced { from: SampleType::U16, bits: 10 }, W::RangeClipped, W::AlphaDiscarded, W::AlphaBinarized, @@ -209,9 +211,19 @@ fn warnings_match_actual_roundtrip_behaviour() { let img = test_image(l, s); let w = warns(&img, f); let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); - let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. })); + let depth_reduced = w.iter().any(|w| match w { + W::DepthReduced { .. } => true, + W::BitsReduced { from, .. } => from.is_float(), + _ => false, + }); let lost_depth = back.sample_type() != s && back.sample_type() != SampleType::F32 && s != SampleType::U8; assert_eq!(depth_reduced, lost_depth, "{f:?} {l:?} {s:?} {w:?} -> {:?}", back.sample_type()); + // Cineon stores 10 bits: 16-bit samples come back changed exactly when warned. + if f == Format::Cineon && s == SampleType::U16 && back.sample_type() == s { + let bits_reduced = w.iter().any(|w| matches!(w, W::BitsReduced { .. })); + let changed = back.to_u16_samples() != img.convert(back.layout(), s).to_u16_samples(); + assert_eq!(bits_reduced, changed, "{f:?} {l:?} {w:?}"); + } let alpha_lost = l.has_alpha() && !back.layout().has_alpha(); assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}"); let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk(); diff --git a/crates/codecs/tests/gif_frames.rs b/crates/codecs/tests/it/gif_frames.rs similarity index 100% rename from crates/codecs/tests/gif_frames.rs rename to crates/codecs/tests/it/gif_frames.rs diff --git a/crates/codecs/tests/heif.rs b/crates/codecs/tests/it/heif.rs similarity index 99% rename from crates/codecs/tests/heif.rs rename to crates/codecs/tests/it/heif.rs index 6471a5ece6..bc1a185ae1 100644 --- a/crates/codecs/tests/heif.rs +++ b/crates/codecs/tests/it/heif.rs @@ -8,7 +8,7 @@ #![cfg(all(feature = "corpus", feature = "heif"))] -mod common; +use crate::common; use common::*; use photocraft_codecs::*; use std::sync::LazyLock; diff --git a/crates/codecs/tests/limits.rs b/crates/codecs/tests/it/limits.rs similarity index 99% rename from crates/codecs/tests/limits.rs rename to crates/codecs/tests/it/limits.rs index 6969fea922..c1873771f6 100644 --- a/crates/codecs/tests/limits.rs +++ b/crates/codecs/tests/it/limits.rs @@ -1,6 +1,6 @@ //! Decompression-bomb guards. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs new file mode 100644 index 0000000000..3e77f071f0 --- /dev/null +++ b/crates/codecs/tests/it/main.rs @@ -0,0 +1,35 @@ +//! The crate's integration tests, linked into one test binary. + +mod camera_raw; +mod common; +mod dds; +mod dds_write; +mod decode_warnings; +mod deep_exr; +mod detect_caps; +mod dpx; +mod exr_cryptomatte; +mod exr_multipart; +mod fidelity; +mod gif_frames; +mod heif; +mod limits; +mod malformed; +mod metadata; +mod orientation; +mod pcx; +mod png_jpeg; +mod pnm_headers; +mod resolution; +mod roundtrip; +mod source_settings; +mod tga; +mod tiff_bench; +mod tiff_exif; +mod tiff_lab; +mod tiff_pages; +mod tiff_photoshop; +mod tiff_regressions; +mod webp_frames; +mod webp_lossy; +mod xpm; diff --git a/crates/codecs/tests/malformed.rs b/crates/codecs/tests/it/malformed.rs similarity index 99% rename from crates/codecs/tests/malformed.rs rename to crates/codecs/tests/it/malformed.rs index b6da84b072..4bb9ee7cb1 100644 --- a/crates/codecs/tests/malformed.rs +++ b/crates/codecs/tests/it/malformed.rs @@ -1,6 +1,6 @@ //! Malformed input must produce errors, never panics or huge allocations. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; use proptest::prelude::*; diff --git a/crates/codecs/tests/metadata.rs b/crates/codecs/tests/it/metadata.rs similarity index 78% rename from crates/codecs/tests/metadata.rs rename to crates/codecs/tests/it/metadata.rs index d8f4d80a9a..f84ec63361 100644 --- a/crates/codecs/tests/metadata.rs +++ b/crates/codecs/tests/it/metadata.rs @@ -1,6 +1,6 @@ //! ICC / EXIF / XMP / DPI / text preservation, per format, per caps. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; @@ -36,8 +36,14 @@ fn check_format(format: Format) { } if c.exif && format == Format::Tiff { // TIFF writes the block's directories as its own and rebuilds a block from them on - // reading (tests/tiff_exif.rs); IFD0's orientation here is the image's. + // reading (tests/it/tiff_exif.rs); IFD0's orientation here is the image's. assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}"); + } else if c.exif && format == Format::WebP { + // WebP's resolution lives in EXIF, so the block comes back with resolution tags added. + let back_exif = back.meta.exif.as_deref().unwrap_or_else(|| panic!("{format:?} lost exif")); + assert_eq!(photocraft_codecs::resolution::exif_resolution(back_exif).map(|(x, y)| (x.round(), y.round())), Some((300.0, 150.0))); + // The rest of the block survives too: the Orientation tag (0x0112, little-endian). + assert!(back_exif.windows(2).any(|w| w == b"\x12\x01"), "{format:?} lost Orientation"); } else if c.exif { assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif"); } else { @@ -295,3 +301,40 @@ fn metadata_survives_depth_conversion() { assert_eq!(back.icc, img.icc); assert_eq!(back.meta.xmp, img.meta.xmp); } + +/// #2399: a WebP export keeps the resolution even when the image has no EXIF (WebP has no +/// resolution field of its own, so a minimal EXIF block carries it), lossless and lossy alike, +/// and no longer warns that it is dropped. +#[test] +fn webp_keeps_dpi_without_source_exif() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); + img.meta = Metadata { dpi: Some((300.0, 150.0)), ..Default::default() }; + assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped)); + for lossless in [true, false] { + let opts = EncodeOptions { webp_lossless: lossless, ..Default::default() }; + let back = decode(&encode(&img, Format::WebP, &opts).unwrap()).unwrap(); + let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("lossless {lossless}: dpi lost")); + assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "lossless {lossless}: {x},{y}"); + } + // EXIF that can't be parsed is written as it is, so the resolution is reported as dropped. + img.meta = Metadata { exif: Some(b"Exif\0\0garbage".to_vec()), dpi: Some((300.0, 150.0)), ..Default::default() }; + assert!(fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped)); + let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap(); + assert!(back.meta.exif.is_some()); + // ...unless the XMP written beside it records the resolution, which readers look at first. + img.meta.xmp = Some(r#""#.into()); + assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped)); + let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap(); + let (x, y) = back.meta.dpi.expect("dpi from XMP"); + assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{x},{y}"); + // The default 72 ppi needs no EXIF (a WebP without one opens at 72), so a plain export stays + // a simple-format file; nor does an image without a resolution. + img.meta = Metadata { dpi: Some((72.0, 72.0)), ..Default::default() }; + assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped)); + let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap(); + assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..])); + // Without a resolution or EXIF there is still no EXIF chunk. + img.meta = Metadata::default(); + let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap(); + assert!(back.meta.exif.is_none()); +} diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/it/orientation.rs similarity index 99% rename from crates/codecs/tests/orientation.rs rename to crates/codecs/tests/it/orientation.rs index c1f176b87c..26eeae030c 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/it/orientation.rs @@ -1,7 +1,7 @@ //! EXIF / TIFF orientation (#285): files are opened upright, and every export //! writes Orientation = 1 so the upright pixels are never rotated twice. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; use proptest::prelude::{any, prop_assert, prop_assert_eq, proptest}; @@ -372,7 +372,7 @@ fn every_layout_and_depth_orients_and_inverts() { } /// #285 performance budget: turning a 24 MP photo upright on open adds ≤ 50 ms -/// (release build: `cargo test --release -p photocraft-codecs --test orientation -- --ignored`). +/// (release build: `cargo test --release -p photocraft-codecs --test it -- orientation:: --ignored`). #[test] #[ignore = "timing; run in release"] fn rotating_24_mp_is_fast() { diff --git a/crates/codecs/tests/pcx.rs b/crates/codecs/tests/it/pcx.rs similarity index 85% rename from crates/codecs/tests/pcx.rs rename to crates/codecs/tests/it/pcx.rs index a5796473e6..792a41021c 100644 --- a/crates/codecs/tests/pcx.rs +++ b/crates/codecs/tests/it/pcx.rs @@ -87,6 +87,25 @@ fn rejects_truncated_and_overshooting_rle_without_panicking() { let mut b = header(8, 3, 2, 1, 2); b[66..68].copy_from_slice(&0u16.to_le_bytes()); assert_eq!(detect(&b), None, "invalid stride is not a PCX signature"); + + // A solid image with stride of the form 63k + 1 (64, 127) encodes as k full 63-byte runs + // plus a 1-byte literal (2k + 1 bytes per plane row), which is valid and must decode. + for width in [64u16, 127] { + let mut solid = header(8, 3, width, 1, width); + for _plane in 0..3 { + let mut rem = usize::from(width); + while rem >= 63 { + solid.extend_from_slice(&[0xff, 0x00]); // 63-byte run of 0x00 + rem -= 63; + } + if rem == 1 { + solid.push(0x00); // 1-byte literal 0x00 + } + } + let im = decode(&solid).unwrap(); + assert_eq!(im.dimensions(), (u32::from(width), 1)); + assert!(im.data().iter().all(|&v| v == 0)); + } } #[test] diff --git a/crates/codecs/tests/png_jpeg.rs b/crates/codecs/tests/it/png_jpeg.rs similarity index 99% rename from crates/codecs/tests/png_jpeg.rs rename to crates/codecs/tests/it/png_jpeg.rs index bd704e9a1a..0b8b42a7b7 100644 --- a/crates/codecs/tests/png_jpeg.rs +++ b/crates/codecs/tests/it/png_jpeg.rs @@ -1,7 +1,7 @@ //! Format-specific behaviour: Adam7 PNG, JPEG colour models, oracle //! cross-checks against the `image` crate, and the optional PngSuite corpus. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/pnm_headers.rs b/crates/codecs/tests/it/pnm_headers.rs similarity index 100% rename from crates/codecs/tests/pnm_headers.rs rename to crates/codecs/tests/it/pnm_headers.rs diff --git a/crates/codecs/tests/resolution.rs b/crates/codecs/tests/it/resolution.rs similarity index 100% rename from crates/codecs/tests/resolution.rs rename to crates/codecs/tests/it/resolution.rs diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/it/roundtrip.rs similarity index 99% rename from crates/codecs/tests/roundtrip.rs rename to crates/codecs/tests/it/roundtrip.rs index 9ba9423509..acbd16c5db 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/it/roundtrip.rs @@ -2,7 +2,7 @@ //! Lossless formats must reproduce the (planned) conversion of the input //! exactly; lossy formats must stay above a PSNR threshold. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/source_settings.rs b/crates/codecs/tests/it/source_settings.rs similarity index 99% rename from crates/codecs/tests/source_settings.rs rename to crates/codecs/tests/it/source_settings.rs index 9da82dbe79..0389e2fa8e 100644 --- a/crates/codecs/tests/source_settings.rs +++ b/crates/codecs/tests/it/source_settings.rs @@ -1,7 +1,7 @@ //! Reading back the settings a lossy file was saved with: a JPEG's quality from its //! quantisation tables, a WebP's lossy or lossless encoding (and its quality). -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/tga.rs b/crates/codecs/tests/it/tga.rs similarity index 100% rename from crates/codecs/tests/tga.rs rename to crates/codecs/tests/it/tga.rs diff --git a/crates/codecs/tests/tiff_bench.rs b/crates/codecs/tests/it/tiff_bench.rs similarity index 97% rename from crates/codecs/tests/tiff_bench.rs rename to crates/codecs/tests/it/tiff_bench.rs index 357c10486e..e17f6466d4 100644 --- a/crates/codecs/tests/tiff_bench.rs +++ b/crates/codecs/tests/it/tiff_bench.rs @@ -2,10 +2,10 @@ //! release: //! //! ```sh -//! cargo test --release -p photocraft-codecs --test tiff_bench -- --ignored --nocapture +//! cargo test --release -p photocraft-codecs --test it -- tiff_bench:: --ignored --nocapture //! ``` -mod common; +use crate::common; use common::tiffgen::*; use photocraft_codecs::*; use std::time::Instant; diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/it/tiff_exif.rs similarity index 100% rename from crates/codecs/tests/tiff_exif.rs rename to crates/codecs/tests/it/tiff_exif.rs diff --git a/crates/codecs/tests/tiff_lab.rs b/crates/codecs/tests/it/tiff_lab.rs similarity index 100% rename from crates/codecs/tests/tiff_lab.rs rename to crates/codecs/tests/it/tiff_lab.rs diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/it/tiff_pages.rs similarity index 99% rename from crates/codecs/tests/tiff_pages.rs rename to crates/codecs/tests/it/tiff_pages.rs index 60885e527d..db432abe51 100644 --- a/crates/codecs/tests/tiff_pages.rs +++ b/crates/codecs/tests/it/tiff_pages.rs @@ -4,7 +4,7 @@ //! offsets past the end, absurd sizes) that must give `Ok` or a clean `Err`, never a panic. //! Every file here is hand-built by `common::tiffgen`. -mod common; +use crate::common; use common::Rng; use common::tiffgen::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/it/tiff_photoshop.rs similarity index 99% rename from crates/codecs/tests/tiff_photoshop.rs rename to crates/codecs/tests/it/tiff_photoshop.rs index 400b72eb0c..238710b68c 100644 --- a/crates/codecs/tests/tiff_photoshop.rs +++ b/crates/codecs/tests/it/tiff_photoshop.rs @@ -2,7 +2,7 @@ //! `Metadata` as opaque bytes: written when present, read from either byte order and from //! BigTIFF, and never a panic or an allocation on malformed input. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/tiff_regressions.rs b/crates/codecs/tests/it/tiff_regressions.rs similarity index 99% rename from crates/codecs/tests/tiff_regressions.rs rename to crates/codecs/tests/it/tiff_regressions.rs index d823f33f4a..8fcb1e570c 100644 --- a/crates/codecs/tests/tiff_regressions.rs +++ b/crates/codecs/tests/it/tiff_regressions.rs @@ -1,4 +1,4 @@ -mod common; +use crate::common; use common::tiffgen::*; use photocraft_codecs::{Image, decode, exif_orientation, upright_exif}; diff --git a/crates/codecs/tests/webp_frames.rs b/crates/codecs/tests/it/webp_frames.rs similarity index 100% rename from crates/codecs/tests/webp_frames.rs rename to crates/codecs/tests/it/webp_frames.rs diff --git a/crates/codecs/tests/webp_lossy.rs b/crates/codecs/tests/it/webp_lossy.rs similarity index 99% rename from crates/codecs/tests/webp_lossy.rs rename to crates/codecs/tests/it/webp_lossy.rs index f0ff2cbbb4..079b9134fd 100644 --- a/crates/codecs/tests/webp_lossy.rs +++ b/crates/codecs/tests/it/webp_lossy.rs @@ -3,7 +3,7 @@ //! smaller than lossless on photographic content; alpha and metadata travel in the container; //! odd sizes and the limits behave. -mod common; +use crate::common; use common::*; use photocraft_codecs::*; diff --git a/crates/codecs/tests/xpm.rs b/crates/codecs/tests/it/xpm.rs similarity index 100% rename from crates/codecs/tests/xpm.rs rename to crates/codecs/tests/it/xpm.rs diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 5cf5fbd691..508c26977e 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -1,7 +1,7 @@ //! Photoshop and Paint.NET blend modes (scalar reference implementation). //! //! Formulas follow the public PDF / ISO 32000-2 and W3C Compositing definitions, -//! with Photoshop's variants where they differ (Soft Light, Hard Mix, Darker/Lighter Color, +//! with Photoshop's variants where they differ (Hard Mix, Darker/Lighter Color, //! Linear Burn/Dodge, Vivid/Linear/Pin Light, Subtract, Divide). This is the CPU reference //! that the GPU kernels must match. @@ -323,10 +323,19 @@ fn hard_light(cb: f32, cs: f32) -> f32 { } } -/// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half). +/// Photoshop's Soft Light: the W3C/PDF curve. In the upper half (`cs > 0.5`) the backdrop moves +/// towards `D(cb)`, which is `sqrt(cb)` above a quarter and the cubic `((16·cb − 12)·cb + 4)·cb` +/// below it; with `sqrt` throughout, dark backdrops came out up to 20 levels too light (#8804, +/// measured on psd-tools `blend-modes/rgb-blend-modes.psd`). #[inline] fn soft_light_ps(cb: f32, cs: f32) -> f32 { - if cs <= 0.5 { 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) } else { 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) } + if cs <= 0.5 { + 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) + } else { + let b = cb.max(0.0); + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + 2.0 * cb * (1.0 - cs) + d * (2.0 * cs - 1.0) + } } // ---- non-separable helpers (W3C Compositing Level 1) ---- @@ -572,6 +581,37 @@ mod tests { assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0)); } + /// #8804: Photoshop's Soft Light over a dark backdrop (cb ≤ 0.25, cs > 0.5) follows the W3C + /// curve `D(cb) = ((16·cb − 12)·cb + 4)·cb`, not `sqrt(cb)`. Measured on psd-tools + /// `blend-modes/rgb-blend-modes.psd` (Photoshop's merged image), in 8-bit levels: + /// (backdrop, source, Photoshop). + #[test] + fn soft_light_follows_photoshop_over_dark_backdrops() { + for (cb, cs, ps) in [(4u8, 247u8, 11.0f32), (6, 254, 19.0), (9, 252, 25.0), (10, 237, 32.0), (14, 253, 46.0)] { + let got = blend_channel(BlendMode::SoftLight, f32::from(cb) / 255.0, f32::from(cs) / 255.0) * 255.0; + assert!((got - ps).abs() <= 7.0, "cb {cb} cs {cs}: {got} vs Photoshop {ps}"); + } + } + + #[test] + fn soft_light_is_the_w3c_formula() { + let d = |cb: f32| if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.sqrt() }; + let w3c = |cb: f32, cs: f32| if cs <= 0.5 { cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb) } else { cb + (2.0 * cs - 1.0) * (d(cb) - cb) }; + for b in (0..=255u8).step_by(3) { + for s in (0..=255u8).step_by(5) { + let (cb, cs) = (f32::from(b) / 255.0, f32::from(s) / 255.0); + let got = blend_channel(BlendMode::SoftLight, cb, cs); + assert!((got - w3c(cb, cs)).abs() < 1e-5, "cb {b} cs {s}: {got} vs {}", w3c(cb, cs)); + } + } + // Continuous where the two halves of D meet (both give 0.5 at cb = 0.25). + let below = blend_channel(BlendMode::SoftLight, 0.25, 1.0); + let above = blend_channel(BlendMode::SoftLight, 0.25 + 1e-6, 1.0); + assert!(close(below, 0.5) && (above - below).abs() < 1e-4, "{below} {above}"); + // A negative backdrop (32-bit documents) counts as black under a light source, as before. + assert!(close(blend_channel(BlendMode::SoftLight, -0.1, 1.0), 0.0)); + } + #[test] fn vivid_light_source_extremes_and_interior_values() { for cb in [0.0, 0.25, 0.5, 0.75, 1.0] { diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 45bdb4bdff..50e8e220ac 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -21,6 +21,7 @@ rayon = { workspace = true } proptest = { workspace = true } criterion = { workspace = true } photocraft-paint = { workspace = true } +photocraft-cms = { workspace = true } [[bench]] name = "compose" diff --git a/crates/compose/proptest-regressions/cmyk_tests.txt b/crates/compose/proptest-regressions/cmyk_tests.txt new file mode 100644 index 0000000000..2f3a9c3fb8 --- /dev/null +++ b/crates/compose/proptest-regressions/cmyk_tests.txt @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] } +cc e14b02709a87770da2637c28678c4fa761b5628f402e0c6d60fe66f1aa4d4bfa # shrinks to s = Spec { w: 3, h: 2, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Group { children: [Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }), blend: Dissolve, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.8972109, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] } +cc 4eeba1f8db987228717b932b82d043d61e82fd897a33ea9e2ce68d6c61d7f491 # shrinks to s = Spec { w: 3, h: 5, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 0.026626235, clipped: false, visible: true, mask: None }, Pixels { kind: Adjust(Invert), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] } diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index d122a09993..cd907e9cfa 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -77,6 +77,12 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) { /// unrounded). Integer depths work on whole levels like Photoshop's (see [`levels_q`] and /// `adjustment_quantum`), 32-bit documents get its float Levels (see [`levels_float`]). pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option) { + apply_depth_mode(adj, buf, transfer, depth, ColorMode::Rgb); +} + +/// [`apply_depth`] in a document of colour `mode` (which decides whether float curves extend, +/// see [`float_tone_extrapolates`]). +fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option, mode: ColorMode) { match adj { Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]), Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]), @@ -143,7 +149,7 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth return; } let luts = tone_luts_depth(adj, depth); - apply_tone(buf, *space, &luts, identity); + apply_tone(buf, *space, &luts, identity, float_tone_extrapolates(adj, depth, mode)); } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges)); @@ -198,8 +204,11 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } Adjustment::BlackWhite { weights, tint } => { let hdr = depth == Some(SampleType::F32); + // 32-bit greys keep their superwhites: Photoshop 27.11 maps negatives to 0 and caps + // neutrals at 16, but leaves 2.0 at 2.0 (#8537). + let hi = if hdr { 16.0 } else { 1.0 }; map_rgb(buf, |c| { - let g = black_white_gray(c, weights); + let g = black_white_gray(c, weights).clamp(0.0, hi); match tint { // Photoshop tints like the Color blend mode: the tint's hue and saturation at // the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In @@ -335,7 +344,7 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O }); } } else { - apply_depth(adj, buf, transfer, depth); + apply_depth_mode(adj, buf, transfer, depth, mode); } } @@ -563,8 +572,8 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option) { } } -/// Float RGB curve channels with no edit must preserve samples outside the LUT domain. -/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle. +/// Float RGB curve channels with no edit must preserve samples outside the LUT domain; edited +/// ones extend past it (see [`float_tone_extrapolates`]). pub fn float_tone_identity(adj: &Adjustment, depth: Option) -> [bool; 3] { match adj { Adjustment::Curves { master, per_channel, space: ToneSpace::Rgb, .. } if depth == Some(SampleType::F32) => { @@ -574,9 +583,18 @@ pub fn float_tone_identity(adj: &Adjustment, depth: Option) -> [bool } } -pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec; 4], identity: [bool; 3]) { +/// Whether an edited Curves adjustment continues past 0..1 instead of clipping: in a 32-bit RGB +/// document Photoshop extends each curve beyond its end points along the slope of its end +/// segments (#7157: `[[0,0],[255,128]]` maps -0.5 to -0.250980407 and 2 to 1.003921628). Only +/// in RGB documents: the curve's space is not the document's, and other modes keep the table. +pub fn float_tone_extrapolates(adj: &Adjustment, depth: Option, mode: ColorMode) -> bool { + matches!(adj, Adjustment::Curves { space: ToneSpace::Rgb, .. }) && depth == Some(SampleType::F32) && mode == ColorMode::Rgb +} + +pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec; 4], identity: [bool; 3], extrapolate: bool) { + let sample = if extrapolate { lut_extended } else { lut }; match space { - ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })), + ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { sample(&luts[i], c[i]) })), ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(space, luts, c)), } } @@ -744,7 +762,8 @@ pub fn range_lightness(c: [f32; 3], l: f32) -> [f32; 3] { /// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a /// secondary-colour part (yellow, cyan or magenta: middle − smallest) and a primary part (red, /// green or blue: largest − middle); the sliders (percent, Photoshop defaults 40 60 40 60 20 80 for -/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts. +/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts. Unclipped: the +/// document's depth decides the range. pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 { let (r, g, b) = (c[0], c[1], c[2]); let (max, min) = (r.max(g).max(b), r.min(g).min(b)); @@ -764,7 +783,7 @@ pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 { } else { 5 // red + blue = magenta }; - (min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0).clamp(0.0, 1.0) + min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0 } /// Ordered-dither offset in -0.5..0.5 (4×4 Bayer matrix) for document pixel (x, y). @@ -992,6 +1011,24 @@ fn lut(table: &[f32], v: f32) -> f32 { table[i] * (1.0 - f) + table[j] * f } +/// Table steps the end slopes of [`lut_extended`] are measured over: one step's difference near +/// 0.5 carries f32 rounding (2e-4 of the slope), while 16 steps are still only 0.4 % of the domain. +pub const TONE_SLOPE_SPAN: usize = 16; + +/// [`lut`] continued past 0..1 along the slope of the table's end segments, as Photoshop applies +/// a curve to float samples ([`float_tone_extrapolates`]). Inside the domain it is [`lut`]; +/// non-finite samples stay with it too, so an infinite one can't become NaN on a flat end. +#[inline] +fn lut_extended(table: &[f32], v: f32) -> f32 { + let n = table.len(); + if !v.is_finite() || (0.0..=1.0).contains(&v) || n < 2 { + return lut(table, v); + } + let k = TONE_SLOPE_SPAN.min(n - 1); + let per_unit = (n - 1) as f32 / k as f32; + if v < 0.0 { table[0] + v * (table[k] - table[0]) * per_unit } else { table[n - 1] + (v - 1.0) * (table[n - 1] - table[n - 1 - k]) * per_unit } +} + /// Photo Filter's linear-light RGB matrix. Photoshop multiplies the pixel's D50 XYZ, relative to /// white, by the filter colour's (mixed in by `density`): a fixed linear map, so it is folded into /// one matrix. With `normalize_y` the map keeps luminance Y (32-bit Preserve Luminosity; 8/16-bit @@ -1299,6 +1336,11 @@ pub fn levels_gamma(u: f32, g: f32) -> f32 { return u.powf(c); } let b = (a * u1 - u1.powf(c)) / u1.powf(q); + // Just above gamma 1 the toe shrinks to nothing: u1^q underflows to 0 while u1 doesn't, and + // the power curve is the toe's limit there (otherwise b = inf and black turns NaN). + if !b.is_finite() { + return u.powf(c); + } a * u - b * u.powf(q) } @@ -2197,6 +2239,22 @@ mod tone_tests { assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d)); } + /// #8537: 32-bit superwhites stay above 1 (Photoshop 27.11: 2.0 stays 2.0, neutrals cap at + /// 16, negatives go to 0); 8-bit still clips at 1. + #[test] + fn black_white_keeps_32_bit_superwhites() { + let adj = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None }; + let run = |depth, c: f32| { + let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c, c, c, 1.0]] }; + apply_depth(&adj, &mut b, Transfer::Srgb, Some(depth)); + b.px[0][0] + }; + assert_eq!(run(SampleType::F32, 2.0), 2.0); + assert_eq!(run(SampleType::F32, 20.0), 16.0); + assert_eq!(run(SampleType::F32, -0.5), 0.0); + assert_eq!(run(SampleType::U8, 2.0), 1.0); + } + /// Photoshop 27.11, measured: Black & White (default weights) with Tint over greys. The tint /// is blended like the Color blend mode: its hue and saturation at the grey's luminosity. #[test] diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 22f374af6e..a0fdf52cb0 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -270,10 +270,12 @@ fn interior_effects_blend_over_the_layer_mode_unless_grouped() { fn interior_effects_blend_with_the_backdrop_through_a_see_through_normal_layer() { // #6232: #04170F under a Normal layer at fill 0 with a yellow Soft Light colour overlay. The // overlay soft-lights the dark green it sees through the layer (Photoshop's soft light of - // yellow: sqrt(b) for red and green, b² for blue), not transparency, which would leave plain - // yellow. A half-transparent part of the layer mixes it in at half strength. + // yellow: the W3C D(b) for red and green, which is the cubic ((16b − 12)b + 4)b below a + // quarter (#8804), and b² for blue), not transparency, which would leave plain yellow. A + // half-transparent part of the layer mixes it in at half strength. let bg: [f32; 3] = [4.0 / 255.0, 23.0 / 255.0, 15.0 / 255.0]; - let lit = [bg[0].sqrt(), bg[1].sqrt(), bg[2] * bg[2]]; + let dark = |b: f32| ((16.0 * b - 12.0) * b + 4.0) * b; + let lit = [dark(bg[0]), dark(bg[1]), bg[2] * bg[2]]; let half = |c: usize| (bg[c] + lit[c]) / 2.0; for depth in DEPTHS { let mut d = doc(depth, false); diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs new file mode 100644 index 0000000000..52db6582d9 --- /dev/null +++ b/crates/compose/src/cmyk.rs @@ -0,0 +1,716 @@ +//! Mode-native CMYK compositing: a CMYK document's layers mixed on their ink values, so opaque +//! pixels keep their exact inks (K-only black stays K-only) instead of going through display RGB +//! and back through the profile. +//! +//! This path covers the layer stacks whose blending is per channel, so it means the same on ink +//! values as on display colour: every blend mode except the ones that mix channels (Hue, +//! Saturation, Color, Luminosity, Darker Color, Lighter Color), with opacity, fill opacity and +//! masks (pixel, type, shape and smart object layers through their rendered pixels, solid colour +//! fills, and fills carrying their rendered pixels), isolated and pass-through groups, and +//! clipping groups. Blend modes act on each ink's brightness (1 − ink), as the RGB compositor's +//! do on each channel, and Dissolve uses the same noise. Type layers mix their edges in the +//! compositor's text gamma, on brightness. Adjustment layers that are defined ink by ink (Invert, +//! Posterize, and Levels and Curves recorded on the CMYK channels) change the inks themselves, +//! rounded to the document's levels like the compositor's. [`supports`] says whether a document +//! stays on it; anything else (gradient and pattern fills without rendered pixels, effects, other +//! adjustments, channel-mixing blend modes, knockouts, channel restrictions, Blend If) is left to +//! the RGB compositor, whose result callers convert back to CMYK. +//! +//! Buffers hold straight-alpha `[C, M, Y, K, A]` in 0..1 ink (0 = no ink). + +use std::sync::Arc; + +use photocraft_color::blend::BlendMode; +use photocraft_color::convert::CmykSpace; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::adjust::ToneSpace; +use photocraft_doc::{Adjustment, Document, Knockout, Layer, LayerContent}; +use photocraft_geom::Rect; + +use super::{Ctx, RENDER_TILE, advanced, empty_in, mask_vals, pattern}; + +#[path = "cmyk_split.rs"] +pub mod split; + +/// One straight-alpha CMYK pixel: C, M, Y, K (0..1 ink), alpha. +pub type Cmyka = [f32; 5]; + +/// Deepest group nesting this path composites (deeper documents use the RGB compositor). +pub const MAX_DEPTH: u32 = 64; + +/// Straight-alpha CMYK + alpha float buffer covering a rectangle. +#[derive(Clone, Debug, PartialEq)] +pub struct CmykBuffer { + pub rect: Rect, + pub px: Vec, +} + +impl CmykBuffer { + pub fn transparent(rect: Rect) -> Self { + Self { rect, px: vec![[0.0; 5]; rect.width() as usize * rect.height() as usize] } + } +} + +/// Whether [`render_cmyk`] composites `doc` natively (a CMYK document made only of layers this +/// path handles; invisible layers don't count). +pub fn supports(doc: &Document) -> bool { + doc.mode == ColorMode::Cmyk && stack_supported(&doc.layers, 0) +} + +/// The sibling list grouped as the compositor groups it: each base with the clipped layers above it. +fn clip_groups(layers: &[Layer]) -> impl Iterator { + let mut i = 0; + std::iter::from_fn(move || { + let base = layers.get(i)?; + let mut j = i + 1; + while layers.get(j).is_some_and(|l| l.clipped) && !base.clipped { + j += 1; + } + let clipped = layers.get(i + 1..j).unwrap_or(&[]); + i = j.max(i + 1); + Some((base, clipped)) + }) +} + +fn stack_supported(layers: &[Layer], depth: u32) -> bool { + if depth > MAX_DEPTH { + return false; + } + clip_groups(layers).all(|(base, clipped)| !base.visible || layer_supported(base, clipped, depth)) +} + +/// What every natively composited layer needs, whatever its content. +fn plain(l: &Layer) -> bool { + l.opacity.is_finite() + && l.fill_opacity.is_finite() + && !super::effects::has_effects(l) + && l.advanced.knockout == Knockout::None + && l.excluded_channels == 0 + && l.blend_if.is_default() +} + +/// A layer whose own pixels this path reads in CMYK: pixel layers, and type, shape and smart +/// object layers through their rendered pixels (rendered in the document's model, so a colour +/// given in CMYK is drawn with exactly those inks); solid colour fills, and fills carrying the +/// pixels they were rendered to. Gradient and pattern fills without those pixels aren't. +fn cmyk_pixels(l: &Layer) -> bool { + let cmyk = |s: &photocraft_raster::Surface| s.format().mode == ColorMode::Cmyk; + match &l.content { + LayerContent::Raster(s) => cmyk(s), + LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => l.surface().is_none_or(cmyk), + LayerContent::Fill(f) => match &l.fill_cache { + Some(c) if c.fill == *f => cmyk(&c.surface), + // Solid colours and gradients render natively in CMYK; patterns don't. + _ => !matches!(f, photocraft_doc::Fill::Pattern { .. }), + }, + _ => false, + } +} + +fn layer_supported(l: &Layer, clipped: &[Layer], depth: u32) -> bool { + if !plain(l) || advanced::clips_individually(l, clipped) { + return false; + } + let content = match &l.content { + // A stroked shape with clipped layers draws its stroke above them (the compositor's + // split path), which this path doesn't do. + LayerContent::Shape(sh) if sh.stroke.is_some() && clipped.iter().any(|c| c.visible) => false, + LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) | LayerContent::Fill(_) => { + cmyk_pixels(l) && separable(l.blend) + } + LayerContent::Group(g) => { + g.artboard.is_none() + && (separable(l.blend) || l.blend == BlendMode::PassThrough) + // A pass-through group with clipped layers mixes them through its isolated rendering. + && !(passes_through(l) && clipped.iter().any(|c| c.visible)) + && stack_supported(&g.children, depth + 1) + } + LayerContent::Adjustment(a) => ink_adjustment(a) && separable(l.blend), + }; + content && clipped.iter().filter(|c| c.visible).all(|c| plain(c) && (cmyk_pixels(c) || adjusts_inks(c)) && separable(c.blend)) +} + +/// Adjustments whose CMYK result is defined ink by ink, applied here on ink values: Invert and +/// Posterize (each channel on its own), and Levels and Curves recorded on the CMYK channels. +fn ink_adjustment(a: &Adjustment) -> bool { + match a { + Adjustment::Invert | Adjustment::Posterize { .. } => true, + Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => *space == ToneSpace::Cmyk, + _ => false, + } +} + +fn adjusts_inks(l: &Layer) -> bool { + matches!(&l.content, LayerContent::Adjustment(a) if ink_adjustment(a)) +} + +/// A group whose children composite straight into the backdrop (the compositor's rule). +fn passes_through(l: &Layer) -> bool { + matches!(l.content, LayerContent::Group(_)) && l.blend == BlendMode::PassThrough && l.fill_opacity >= 1.0 && !super::effects::has_effects(l) +} + +/// Normal compositing of straight-alpha `source` (alpha times `opacity`) over `backdrop`: the +/// compositor's Normal formula, on the four ink channels. +#[inline] +pub fn normal(backdrop: Cmyka, source: Cmyka, opacity: f32) -> Cmyka { + let ab = backdrop[4]; + let as_ = source[4] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let ao = as_ + ab * (1.0 - as_); + if ao <= 0.0 { + return [0.0; 5]; + } + let kb = ab * (1.0 - as_); + let inv = 1.0 / ao; + // Over nothing, or where both sides agree, the result is exactly the source's ink (the mix + // would only add rounding, which modes that jump at 0 or 1 ink could magnify). + let c = |i: usize| if kb <= 0.0 || backdrop[i] == source[i] { source[i] } else { (kb * backdrop[i] + as_ * source[i]) * inv }; + [c(0), c(1), c(2), c(3), ao] +} + +/// Blend modes that work channel by channel, which this path applies to each ink (the others +/// mix the channels together: hue, saturation, colour, luminosity, darker and lighter colour). +pub fn separable(mode: BlendMode) -> bool { + !matches!( + mode, + BlendMode::PassThrough + | BlendMode::Hue + | BlendMode::Saturation + | BlendMode::Color + | BlendMode::Luminosity + | BlendMode::DarkerColor + | BlendMode::LighterColor + ) +} + +/// The blended inks of a separable `mode`: the compositor's blend function applied to each +/// channel's brightness (1 − ink), as CMYK channels are blended, so Multiply adds ink and Screen +/// takes it away. +pub fn blend_ink(mode: BlendMode, cb: [f32; 4], cs: [f32; 4]) -> [f32; 4] { + let b = |i: usize| 1.0 - cb[i]; + let s = |i: usize| 1.0 - cs[i]; + let cmy = super::psblend::blend_rgb(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)]); + let k = super::psblend::blend_rgb(mode, [b(3); 3], [s(3); 3]); + // A result that is one of the inputs (Normal, Darken, Lighten, ...) is that ink exactly. + let ink = |r: f32, i: usize| { + if r == s(i) { + cs[i] + } else if r == b(i) { + cb[i] + } else { + 1.0 - r + } + }; + [ink(cmy[0], 0), ink(cmy[1], 1), ink(cmy[2], 2), ink(k[0], 3)] +} + +/// [`composite_ink`] for a layer with separate `opacity` and `fill`, as the RGB compositor's +/// `psblend::composite_fill` does on each channel: for the special eight below 100% Fill the +/// source is blended at full strength pulled toward the mode's neutral colour +/// (`psblend::blend_rgb_fill`, on each ink's brightness), with Opacity as its coverage over the +/// backdrop; over transparency Fill stays coverage. +pub fn composite_ink_fill(mode: BlendMode, backdrop: Cmyka, source: Cmyka, opacity: f32, fill: f32, gamma: f32) -> Cmyka { + let f = fill.clamp(0.0, 1.0); + if f >= 1.0 || !super::psblend::fill_is_special(mode) { + return composite_ink(mode, backdrop, source, opacity * f, gamma); + } + let ab = backdrop[4]; + let over = source[4] * opacity; + let alone = over * f; + if over <= 0.0 { + return backdrop; + } + let ao = ab + (1.0 - ab) * alone; + if ao <= 0.0 { + return [0.0; 5]; + } + let b = |i: usize| 1.0 - backdrop[i]; + let s = |i: usize| 1.0 - source[i]; + let cmy = super::psblend::blend_rgb_fill(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)], f); + let k = super::psblend::blend_rgb_fill(mode, [b(3); 3], [s(3); 3], f); + let bl = [cmy[0], cmy[1], cmy[2], k[0]]; + let (wb, wbs, ws) = (ab * (1.0 - over) / ao, ab * over / ao, (1.0 - ab) * alone / ao); + // Mixed as brightness, in the type gamma when there is one. + let enc = |v: f32| if gamma == 1.0 { v } else { super::psblend::text_encode(v, gamma) }; + let dec = |v: f32| if gamma == 1.0 { v } else { super::psblend::text_decode(v, gamma) }; + let c = |i: usize| 1.0 - dec(wb * enc(b(i)) + wbs * enc(bl[i]) + ws * enc(s(i))); + [c(0), c(1), c(2), c(3), ao] +} + +/// Straight-alpha `source` (alpha times `opacity`) composited onto `backdrop` in a separable +/// `mode` (the general compositing formula, on inks), coverage mixed in the type `gamma` (1 = +/// none). Normal is [`normal_gamma`]. +pub fn composite_ink(mode: BlendMode, backdrop: Cmyka, source: Cmyka, opacity: f32, gamma: f32) -> Cmyka { + if mode == BlendMode::Normal { + return normal_gamma(backdrop, source, opacity, gamma); + } + let ab = backdrop[4]; + let as_ = source[4] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let ao = as_ + ab * (1.0 - as_); + if ao <= 0.0 { + return [0.0; 5]; + } + let cb = [backdrop[0], backdrop[1], backdrop[2], backdrop[3]]; + let cs = [source[0], source[1], source[2], source[3]]; + let bl = blend_ink(mode, cb, cs); + let (wb, ws, wbs) = ((1.0 - as_) * ab / ao, (1.0 - ab) * as_ / ao, as_ * ab / ao); + let c = |i: usize| { + if gamma == 1.0 { + wb * cb[i] + ws * cs[i] + wbs * bl[i] + } else { + let g = |ink: f32| super::psblend::text_encode(1.0 - ink, gamma); + 1.0 - super::psblend::text_decode(wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(bl[i]), gamma) + } + }; + [c(0), c(1), c(2), c(3), ao] +} + +/// A finite sample in 0..1 (non-finite samples, possible in 32-bit documents, count as 0). +#[inline] +fn clean(v: f32) -> f32 { + if v.is_finite() { v } else { 0.0 } +} + +/// [`normal`] with coverage mixed in the type-layer `gamma` space (1 = plain [`normal`]), as +/// the compositor mixes type edges. The space is one of light, so inks are mixed as their +/// brightness (1 − ink); fully covered pixels are the source's inks exactly. +#[inline] +pub fn normal_gamma(backdrop: Cmyka, source: Cmyka, opacity: f32, gamma: f32) -> Cmyka { + if gamma == 1.0 { + return normal(backdrop, source, opacity); + } + let ab = backdrop[4]; + let as_ = source[4] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let ao = as_ + ab * (1.0 - as_); + if ao <= 0.0 { + return [0.0; 5]; + } + // Normal: the blended colour is the source's, so its weight joins the source's. + let (wb, ws) = ((1.0 - as_) * ab / ao, as_ / ao); + let g = |ink: f32| super::psblend::text_encode(1.0 - ink, gamma); + // A covered pixel, or a channel both sides agree on, is exactly that ink: the round trip + // through the gamma space would only add rounding (K-only type would gain a trace of CMY). + let c = |i: usize| { + if wb <= 0.0 || backdrop[i] == source[i] { source[i] } else { 1.0 - super::psblend::text_decode(wb * g(backdrop[i]) + ws * g(source[i]), gamma) } + }; + [c(0), c(1), c(2), c(3), ao] +} + +/// A layer's own CMYK pixels over `rect` (see [`cmyk_pixels`]), masks applied. +fn layer_content(l: &Layer, rect: Rect, cx: &Ctx) -> CmykBuffer { + let mut out = CmykBuffer::transparent(rect); + let rendered; + let surface = match &l.content { + LayerContent::Fill(f) => match &l.fill_cache { + Some(c) if c.fill == *f => Some(&c.surface), + _ => { + // The compositor's own CMYK rendering of the fill, at the document's depth (a + // colour given in CMYK keeps its inks); a solid colour is one pixel, spread out. + let solid = matches!(f, photocraft_doc::Fill::Solid(_)); + let at = if solid { Rect::new(0, 0, 1, 1) } else { rect }; + rendered = super::gradient_fill::render_cmyk_fill(f, at, super::fill_frame(l, cx.canvas), cx.depth); + match &rendered { + Some(s) if solid => { + let p = s.pixel(0, 0); + let v = |i: usize| p.get(i).copied().map_or(0.0, clean); + out.px.fill([v(0), v(1), v(2), v(3), v(4)]); + None + } + r => r.as_ref(), + } + } + }, + _ => l.surface(), + }; + if let Some(s) = surface { + let ch = s.format().channels(); + let alpha = s.format().alpha; + let mut v = Vec::new(); + s.read_region_into(rect, &mut v); + for (o, p) in out.px.iter_mut().zip(v.chunks_exact(ch.max(1))) { + let a = if alpha { p.get(4).copied().map_or(0.0, clean) } else { 1.0 }; + *o = [ + p.first().copied().map_or(0.0, clean), + p.get(1).copied().map_or(0.0, clean), + p.get(2).copied().map_or(0.0, clean), + p.get(3).copied().map_or(0.0, clean), + a, + ]; + } + } + apply_mask(l, &mut out, cx); + out +} + +fn apply_mask(l: &Layer, buf: &mut CmykBuffer, cx: &Ctx) { + if let Some(m) = mask_vals(l, buf.rect, cx) { + for (p, k) in buf.px.iter_mut().zip(&m) { + p[4] *= clean(*k).max(0.0); + } + } +} + +/// Mask value `i` of `m` (1 where there is no mask or no value). +#[inline] +fn mask_at(m: &Option>, i: usize) -> f32 { + m.as_ref().and_then(|v| v.get(i)).copied().map_or(1.0, |k| clean(k).max(0.0)) +} + +/// `table` (samples evenly over 0..1) at `v`, interpolated like the compositor's adjustment LUTs. +fn lut_at(table: &[f32], v: f32) -> f32 { + let Some(last) = table.len().checked_sub(1) else { return v }; + let x = clean(v).clamp(0.0, 1.0) * last as f32; + let i = (x.floor() as usize).min(last); + let f = x - i as f32; + let at = |k: usize| table.get(k).copied().unwrap_or(v); + at(i) * (1.0 - f) + at((i + 1).min(last)) * f +} + +/// Applies an [`ink_adjustment`] to every covered pixel. Like the document's channels, the +/// adjustment acts on each ink's brightness (1 − ink); Levels and Curves work on the whole levels +/// of a `depth` document as on the RGB compositor. +fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) { + let luts = matches!(adj, Adjustment::Levels { .. } | Adjustment::Curves { .. }).then(|| super::adjust::tone_luts_depth(adj, Some(depth))); + let tone = |ch: usize, v: f32| match adj { + Adjustment::Invert => 1.0 - v, + Adjustment::Posterize { levels } => super::adjust::posterize(v, *levels), + _ => luts.as_ref().and_then(|t| t.get(ch)).map_or(v, |t| lut_at(t, v)), + }; + for p in &mut buf.px { + if p[4] <= 0.0 { + continue; + } + for (ch, ink) in p.iter_mut().take(4).enumerate() { + // A channel the adjustment leaves alone keeps its ink exactly (1 − (1 − x) can differ). + let (v, r) = (1.0 - *ink, tone(ch, 1.0 - *ink)); + if r != v { + *ink = 1.0 - r; + } + } + } +} + +/// Mixes an adjustment's result back into `base` in the layer's blend mode, by its opacity and +/// mask; coverage is unchanged (the compositor's adjustment mix). +fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) { + let opacity = l.opacity * l.fill_opacity; + let rect = base.rect; + let w = (rect.width() as usize).max(1); + let mv = mask_vals(l, rect, cx); + for (i, (p, a)) in base.px.iter_mut().zip(&adjusted.px).enumerate() { + let k = opacity * mask_at(&mv, i); + if k <= 0.0 { + continue; + } + if l.blend == BlendMode::Dissolve { + // As on the RGB compositor (#3050): opacity and mask are the chance a pixel takes the + // adjustment fully, by the same noise. + let x = rect.x0.saturating_add((i % w) as i32); + let y = rect.y0.saturating_add((i / w) as i32); + if super::dissolve_noise(x, y) < k { + *p = [a[0], a[1], a[2], a[3], p[4]]; + } + continue; + } + let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]); + for (v, b) in p.iter_mut().zip(bl) { + *v = if k >= 1.0 { b } else { *v + (b - *v) * k }; + } + } +} + +/// Rounds to the document's levels after an adjustment layer, as the compositor does (on the +/// channel brightness the document stores, and coverage). Also used by [`super::native`]. +pub(crate) fn quantize_inks(px: &mut [Cmyka], q: f32) { + let round = |v: f32| (v * q + 0.5).floor() / q; + for p in px { + for v in p.iter_mut().take(4) { + // Whole levels of ink: exactly the stored values, rounding ties as on brightness. + *v = (q - ((1.0 - *v) * q + 0.5).floor()) / q; + } + p[4] = round(p[4]); + } +} + +/// Clipped layers onto their base's own rendering: each blends as if the base were opaque and +/// the base keeps its alpha. +fn composite_clipped(clipped: &[Layer], base: &mut CmykBuffer, cx: &Ctx) { + for c in clipped.iter().filter(|c| c.visible) { + if let LayerContent::Adjustment(adj) = &c.content { + let mut adjusted = base.clone(); + adjust_inks(adj, &mut adjusted, cx.depth); + mix_adjusted(c, base, &adjusted, cx); + continue; + } + let content = layer_content(c, base.rect, cx); + let opacity = c.opacity * c.fill_opacity; + let gamma = super::text_gamma(c); + let rect = base.rect; + let w = (rect.width() as usize).max(1); + for (i, (p, s)) in base.px.iter_mut().zip(&content.px).enumerate() { + let alpha = p[4]; + if alpha <= 0.0 { + continue; + } + if c.blend == BlendMode::Dissolve { + // As on the RGB compositor (#3052): the base keeps its alpha and each pixel takes + // the clipped layer fully or not at all, by the same noise. + let x = rect.x0.saturating_add((i % w) as i32); + let y = rect.y0.saturating_add((i / w) as i32); + if super::dissolve_noise(x, y) < s[4] * opacity { + *p = [s[0], s[1], s[2], s[3], alpha]; + } + continue; + } + let r = composite_ink(c.blend, [p[0], p[1], p[2], p[3], 1.0], *s, opacity, gamma); + *p = [r[0], r[1], r[2], r[3], alpha]; + } + } +} + +/// A layer's isolated rendering onto the backdrop in its blend mode. Dissolve shows each pixel +/// fully or not at all, by the compositor's noise (the same pixels as on the RGB compositor). +fn blend_into(backdrop: &mut CmykBuffer, src: &CmykBuffer, mode: BlendMode, opacity: f32, gamma: f32) { + let rect = backdrop.rect; + let w = (rect.width() as usize).max(1); + for (i, (b, s)) in backdrop.px.iter_mut().zip(&src.px).enumerate() { + if s[4] <= 0.0 { + continue; + } + if mode == BlendMode::Dissolve { + let x = rect.x0.saturating_add((i % w) as i32); + let y = rect.y0.saturating_add((i / w) as i32); + let shown = super::dissolve_noise(x, y) < s[4] * opacity; + *b = normal(*b, [s[0], s[1], s[2], s[3], if shown { 1.0 } else { 0.0 }], 1.0); + continue; + } + *b = composite_ink(mode, *b, *s, opacity, gamma); + } +} + +fn composite_stack(layers: &[Layer], backdrop: &mut CmykBuffer, cx: &Ctx) { + for (base, clipped) in clip_groups(layers) { + if base.visible { + composite_layer(base, clipped, backdrop, cx); + if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, super::adjustment_quantum(cx.depth)) { + quantize_inks(&mut backdrop.px, q); + } + } + } +} + +fn composite_layer(l: &Layer, clipped: &[Layer], backdrop: &mut CmykBuffer, cx: &Ctx) { + let rect = backdrop.rect; + if empty_in(l, rect) { + return; + } + let opacity = l.opacity * l.fill_opacity; + if let LayerContent::Group(g) = &l.content { + if passes_through(l) { + let needs_mix = opacity < 1.0 || l.mask.is_some() || l.vector_mask.is_some(); + let before = needs_mix.then(|| backdrop.clone()); + composite_stack(&g.children, backdrop, cx); + if let Some(before) = &before { + // The compositor's pass-through mix: coverage mixes premultiplied colour. + let mv = mask_vals(l, rect, cx); + for (i, (p, a)) in backdrop.px.iter_mut().zip(&before.px).enumerate() { + let k = opacity * mask_at(&mv, i); + let b = *p; + let wa = a[4] * (1.0 - k); + let wb = b[4] * k; + let alpha = wa + wb; + *p = if alpha > 0.0 { + let c = |c: usize| (a[c] * wa + b[c] * wb) / alpha; + [c(0), c(1), c(2), c(3), alpha] + } else { + [0.0; 5] + }; + } + } + return; + } + let mut content = CmykBuffer::transparent(rect); + composite_stack(&g.children, &mut content, cx); + apply_mask(l, &mut content, cx); + composite_clipped(clipped, &mut content, cx); + // As the compositor blends an isolated group: Fill apart from opacity for the special eight. + blend_into_fill(backdrop, &content, l, 1.0); + return; + } + // Adjustment layers transform the backdrop, then blend the result back in. + if let LayerContent::Adjustment(adj) = &l.content { + let mut adjusted = backdrop.clone(); + adjust_inks(adj, &mut adjusted, cx.depth); + composite_clipped(clipped, &mut adjusted, cx); + mix_adjusted(l, backdrop, &adjusted, cx); + return; + } + let mut content = layer_content(l, rect, cx); + composite_clipped(clipped, &mut content, cx); + blend_into_fill(backdrop, &content, l, super::text_gamma(l)); +} + +/// A layer's own rendering onto the backdrop with its opacity and fill: for Photoshop's special +/// eight ([`super::psblend::fill_is_special`]) Fill below 100% is part of the blend +/// ([`composite_ink_fill`]), as on the RGB compositor; otherwise both are coverage. +fn blend_into_fill(backdrop: &mut CmykBuffer, src: &CmykBuffer, l: &Layer, gamma: f32) { + if l.fill_opacity >= 1.0 || !super::psblend::fill_is_special(l.blend) { + return blend_into(backdrop, src, l.blend, l.opacity * l.fill_opacity, gamma); + } + for (b, s) in backdrop.px.iter_mut().zip(&src.px) { + if s[4] > 0.0 { + *b = composite_ink_fill(l.blend, *b, *s, l.opacity, l.fill_opacity, gamma); + } + } +} + +/// One tile on ink values. `space` is the document's CMYK profile ([`super::cmyk_space`]): a +/// colour given in RGB or Lab (a fill, say) becomes ink through it, and tiles run on worker +/// threads, which don't inherit the caller's scope. +fn render_tile(doc: &Document, rect: Rect, cx: &Ctx, space: Option<&Arc>) -> CmykBuffer { + let mut b = CmykBuffer::transparent(rect); + // Blends stay clipped to 0..1 whatever the depth: unclipped (HDR) blending would make + // brightness above 1, i.e. negative ink. Set explicitly, since a tile can run on a thread + // that is in the middle of an RGB composite of a 32-bit document. + photocraft_color::convert::with_cmyk_space(space, || super::in_blend_space(false, false, || composite_stack(&doc.layers, &mut b, cx))); + b +} + +/// Whether [`render_cmyk`] composites `doc` on ink values: on this module's exact path +/// ([`supports`]) or, for layer effects and knockouts, ink by ink through the RGB compositor +/// ([`split::supports`]). +pub fn renders_inks(doc: &Document) -> bool { + supports(doc) || split::supports(doc) +} + +/// The composite of `doc` over `rect` in CMYK + alpha, mixed on ink values; `None` unless +/// [`renders_inks`]. Tiles render in parallel on native targets (one at a time on wasm); +/// every tile is independent, so the result doesn't depend on the tiling or the thread count. +pub fn render_cmyk(doc: &Document, rect: Rect) -> Option { + render_cmyk_tiled(doc, rect, RENDER_TILE) +} + +/// [`render_cmyk`] as display RGB (straight alpha), each pixel's inks shown through the +/// document's CMYK profile as the RGB compositor reads CMYK pixels; `None` unless +/// [`renders_inks`]. What a canvas showing the ink composite would draw. +pub fn render_display(doc: &Document, rect: Rect) -> Option { + let inks = render_cmyk(doc, rect)?; + let space = super::cmyk_space(doc); + let show = |part: &[Cmyka]| -> Vec<[f32; 4]> { + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + part.iter() + .map(|p| { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect() + }) + }; + // Rows of tiles at a time, on every core (the profile is set per worker thread). + let chunk = (rect.width() as usize).max(1) * RENDER_TILE as usize; + #[cfg(not(target_arch = "wasm32"))] + let px: Vec<[f32; 4]> = { + use rayon::prelude::*; + inks.px.par_chunks(chunk).flat_map_iter(show).collect() + }; + #[cfg(target_arch = "wasm32")] + let px: Vec<[f32; 4]> = inks.px.chunks(chunk).flat_map(show).collect(); + Some(super::Buffer { rect, px }) +} + +/// [`render_cmyk`] with an explicit tile size (tests check tile independence). +pub fn render_cmyk_tiled(doc: &Document, rect: Rect, tile: i32) -> Option { + if supports(doc) { + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + let cx = Ctx::for_doc(doc, &patterns); + let space = super::cmyk_space(doc); + return Some(render_with(rect, tile, |t| render_tile(doc, t, &cx, space.as_ref()))); + } + if !split::supports(doc) { + return None; + } + let mut out = CmykBuffer::transparent(rect); + let done = split::render_bands(doc, rect, 0, tile, |band| { + place(&mut out, &band); + Ok::<(), ()>(()) + }); + done.ok().map(|()| out) +} + +/// Copies `part` into `out` where their rectangles overlap (`part` lies within `out`). +fn place(out: &mut CmykBuffer, part: &CmykBuffer) { + let (rect, w) = (out.rect, out.rect.width() as usize); + let pw = part.rect.width() as usize; + for (row, src) in part.px.chunks_exact(pw.max(1)).enumerate() { + let o = ((part.rect.y0 - rect.y0) as usize + row) * w + (part.rect.x0 - rect.x0) as usize; + if let Some(dst) = out.px.get_mut(o..o + pw) { + dst.copy_from_slice(src); + } + } +} + +/// `rect` rendered tile by tile by `render_tile`, tiles in parallel on native targets. +fn render_with(rect: Rect, tile: i32, render_tile: impl Fn(Rect) -> CmykBuffer + Sync) -> CmykBuffer { + let tile = tile.max(1); + let mut out = CmykBuffer::transparent(rect); + if rect.is_empty() { + return out; + } + let mut tiles = Vec::new(); + let mut y = rect.y0; + while y < rect.y1 { + let y1 = y.saturating_add(tile).min(rect.y1); + let mut x = rect.x0; + while x < rect.x1 { + let x1 = x.saturating_add(tile).min(rect.x1); + tiles.push(Rect::new(x, y, x1, y1)); + x = x1; + } + y = y1; + } + #[cfg(not(target_arch = "wasm32"))] + let parts: Vec = { + use rayon::prelude::*; + tiles.par_iter().map(|t| render_tile(*t)).collect() + }; + #[cfg(target_arch = "wasm32")] + let parts: Vec = tiles.iter().map(|t| render_tile(*t)).collect(); + for part in &parts { + place(&mut out, part); + } + out +} + +/// [`render_cmyk`] in horizontal bands, top to bottom, handing each band to `sink` (so a huge +/// document is never held as one full-size float composite); `None` unless [`renders_inks`]. +/// `band_rows` as in [`super::render_bands`]. +pub fn render_cmyk_bands(doc: &Document, rect: Rect, band_rows: i32, mut sink: impl FnMut(CmykBuffer) -> Result<(), E>) -> Option> { + if !supports(doc) { + return split::supports(doc).then(|| split::render_bands(doc, rect, band_rows, RENDER_TILE, sink)); + } + let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); + let cx = Ctx::for_doc(doc, &patterns); + let space = super::cmyk_space(doc); + let rows = super::band_rows_for(rect.width(), band_rows); + let mut y = rect.y0; + while y < rect.y1 { + let y1 = y.saturating_add(rows).min(rect.y1); + if let Err(e) = sink(render_with(Rect::new(rect.x0, y, rect.x1, y1), RENDER_TILE, |t| render_tile(doc, t, &cx, space.as_ref()))) { + return Some(Err(e)); + } + y = y1; + } + Some(Ok(())) +} + +#[cfg(test)] +#[path = "cmyk_tests.rs"] +mod tests; diff --git a/crates/compose/src/cmyk_split.rs b/crates/compose/src/cmyk_split.rs new file mode 100644 index 0000000000..39040c7e94 --- /dev/null +++ b/crates/compose/src/cmyk_split.rs @@ -0,0 +1,377 @@ +//! CMYK documents with layer effects (and stack structures the ink path leaves alone, such as +//! knockouts), composited ink by ink through the RGB compositor itself. +//! +//! Everything allowed here works channel by channel: per-channel blend modes, effects built from +//! coverage and painted with colours or colour gradients, the ink-by-ink adjustments, masks, +//! groups, clipping and knockouts. So the RGB compositor run on a stand-in document whose red, +//! green and blue hold the brightness (1 − ink) of cyan, magenta and yellow gives exactly those +//! inks' result, and a second run with black's brightness in all three gives black. Blends stay +//! clipped to 0..1 (unclipped blending would make negative ink), as on the ink path. +//! +//! 32-bit documents stay on the RGB compositor (32-bit CMYK isn't a print mode, and 32-bit Levels +//! use their own float formula); so do pattern paints, bevel textures, effect blend modes that mix +//! channels, Blend If and channel restrictions, which don't work channel by channel. + +use photocraft_color::blend::BlendMode; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::adjust::ToneSpace; +use photocraft_doc::effects::{Effect, FxPaint}; +use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +use super::{CmykBuffer, MAX_DEPTH, clean, cmyk_pixels, ink_adjustment, separable}; +use crate::{Buffer, Ctx, advanced, pattern}; + +/// Which ink (0..=3: C, M, Y, K) a stand-in document carries in its red, green and blue. +pub(super) type Planes = [usize; 3]; +const CMY: Planes = [0, 1, 2]; +const K: Planes = [3, 3, 3]; + +/// Whether `doc` composites ink by ink on this path. +pub fn supports(doc: &Document) -> bool { + doc.mode == ColorMode::Cmyk && doc.depth != SampleType::F32 && stack_supported(&doc.layers, 0) +} + +fn stack_supported(layers: &[Layer], depth: u32) -> bool { + depth <= MAX_DEPTH && layers.iter().filter(|l| l.visible).all(|l| layer_supported(l, depth)) +} + +fn layer_supported(l: &Layer, depth: u32) -> bool { + let plain = l.opacity.is_finite() && l.fill_opacity.is_finite() && l.excluded_channels == 0 && l.blend_if.is_default(); + // Effects on an adjustment layer (which has no pixels of its own to shape them) stay on the RGB + // compositor. + let effects = !crate::effects::has_effects(l) + || (!matches!(l.content, LayerContent::Adjustment(_)) && l.effects.items.iter().filter(|e| e.enabled()).all(effect_supported)); + let content = match &l.content { + LayerContent::Group(g) => g.artboard.is_none() && (separable(l.blend) || l.blend == BlendMode::PassThrough) && stack_supported(&g.children, depth + 1), + LayerContent::Adjustment(a) => ink_adjustment(a) && separable(l.blend), + // A stroked shape can draw its stroke from the vector, in its own colour. + LayerContent::Shape(sh) if sh.stroke.is_some() => false, + _ => cmyk_pixels(l) && separable(l.blend), + }; + // Effects and knockouts are laid out over the layer's bounds: they must be the same on the + // stand-ins (see [`stand_in_surface`]). + let shaped = crate::effects::has_effects(l) || l.advanced.knockout != photocraft_doc::Knockout::None; + plain && effects && content && (!shaped || transparent_outside(l)) +} + +/// Whether every surface `l` draws from has alpha and a transparent default pixel. +fn transparent_outside(l: &Layer) -> bool { + match &l.content { + LayerContent::Group(g) => g.children.iter().all(transparent_outside), + _ => l.surface().is_none_or(|s| s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0)), + } +} + +fn effect_supported(e: &Effect) -> bool { + let paint = |p: &FxPaint| !matches!(p, FxPaint::Pattern { .. }); + match e { + Effect::DropShadow(s) | Effect::InnerShadow(s) => separable(s.common.blend), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => separable(g.common.blend) && paint(&g.paint), + Effect::Stroke(s) => separable(s.common.blend) && paint(&s.paint), + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } => separable(common.blend), + Effect::Satin(s) => separable(s.common.blend), + Effect::BevelEmboss(b) => separable(b.highlight.blend) && separable(b.shadow.blend) && b.texture.is_none(), + Effect::PatternOverlay { .. } => false, + } +} + +/// The brightness (1 − ink) of ink `i` of a CMYK pixel or colour (0 for a missing or non-finite +/// value: no ink). +fn brightness(inks: &[f32], i: usize) -> f32 { + 1.0 - inks.get(i).copied().map_or(0.0, clean) +} + +fn stand_in_color(c: &Color, planes: Planes) -> Color { + let ink = c.in_mode(ColorMode::Cmyk).c; + Color { mode: ColorMode::Rgb, c: [brightness(&ink, planes[0]), brightness(&ink, planes[1]), brightness(&ink, planes[2]), 0.0], alpha: c.alpha } +} + +/// A CMYK surface as an RGB one (same depth and alpha) holding the planes' brightness. +fn stand_in_surface(s: &Surface, planes: Planes) -> Surface { + stand_in_surface_in(s, planes, crate::BAND_PIXELS) +} + +/// [`stand_in_surface`], converting about `budget` pixels' worth of tiles at a time. +fn stand_in_surface_in(s: &Surface, planes: Planes, budget: u64) -> Surface { + let from = s.format(); + if from.mode != ColorMode::Cmyk { + return s.clone(); + } + let to = PixelFormat { mode: ColorMode::Rgb, sample: from.sample, alpha: from.alpha }; + let n = from.channels().max(1); + let px = |p: &[f32], dst: &mut Vec| { + dst.extend(planes.iter().map(|&i| brightness(p, i))); + if from.alpha { + dst.push(p.get(4).copied().unwrap_or(0.0)); + } + }; + let source_default = s.default_pixel(); + let mut default = Vec::new(); + px(&source_default, &mut default); + let map = |src: &[f32], dst: &mut Vec| { + for p in src.chunks_exact(n) { + let start = dst.len(); + px(p, dst); + // Layer bounds count the pixels that differ from the default, transparent ones too: a + // transparent pixel whose other inks differ keeps a colour of its own here (it never + // shows), so the stand-in's bounds are the document's. + if from.alpha + && p.get(4) == Some(&0.0) + && *p != *source_default + && dst.get(start..) == Some(&default[..]) + && let Some(v) = dst.get_mut(start) + { + *v = if *v > 0.5 { 0.0 } else { 1.0 }; + } + } + }; + let mut out = Surface::with_default(to, &default); + let rects: Vec = s.tiles().map(|(c, _)| c.rect()).collect(); + let convert = |r: &Rect| { + let (mut src, mut dst) = (Vec::new(), Vec::new()); + s.read_region_into(*r, &mut src); + map(&src, &mut dst); + (*r, dst) + }; + // Converted a band's worth of tiles at a time, so the float samples in flight stay about + // one render band's, not the whole layer's. + let mut start = 0; + while start < rects.len() { + let mut end = start; + let mut pixels = 0u64; + while let Some(r) = rects.get(end) + && (end == start || pixels < budget) + { + pixels = pixels.saturating_add(u64::from(r.width()) * u64::from(r.height())); + end += 1; + } + let batch = rects.get(start..end).unwrap_or_default(); + #[cfg(not(target_arch = "wasm32"))] + let tiles: Vec<(Rect, Vec)> = { + use rayon::prelude::*; + batch.par_iter().map(convert).collect() + }; + #[cfg(target_arch = "wasm32")] + let tiles: Vec<(Rect, Vec)> = batch.iter().map(convert).collect(); + for (r, data) in &tiles { + out.write_region(*r, data); + } + start = end; + } + out +} + +/// A fill's colours as stand-ins. A solid colour is first stored at the document's depth, as the +/// compositor renders CMYK solid fills (`gradient_fill::render_cmyk_fill`). +fn stand_in_fill(f: &mut Fill, planes: Planes, depth: SampleType) { + match f { + Fill::Solid(c) => { + let one = Rect::new(0, 0, 1, 1); + let stored = crate::gradient_fill::render_cmyk_fill(&Fill::Solid(*c), one, one, depth).map(|s| s.pixel(0, 0)); + let ink = stored.as_deref().map_or(c.in_mode(ColorMode::Cmyk).c, |p| std::array::from_fn(|i| p.get(i).copied().unwrap_or(0.0))); + let alpha = stored.as_deref().and_then(|p| p.get(4).copied()).unwrap_or(c.alpha); + *c = stand_in_color(&Color { mode: ColorMode::Cmyk, c: ink, alpha }, planes); + } + Fill::Gradient { stops, .. } => stops.iter_mut().for_each(|s| s.1 = stand_in_color(&s.1, planes)), + _ => {} + } +} + +/// Levels and Curves on the CMYK channels as RGB records of the planes' inks (the composite +/// record stays the composite). +fn stand_in_adjustment(a: &mut Adjustment, planes: Planes) { + match a { + Adjustment::Levels { per_channel, space, black, .. } if *space == ToneSpace::Cmyk => { + let ink = |i: usize| if i == 3 { black.clone() } else { per_channel.get(i).cloned().unwrap_or_default() }; + *per_channel = [ink(planes[0]), ink(planes[1]), ink(planes[2])]; + *black = Default::default(); + *space = ToneSpace::Rgb; + } + Adjustment::Curves { per_channel, space, black, .. } if *space == ToneSpace::Cmyk => { + let ink = |i: usize| if i == 3 { black.clone() } else { per_channel.get(i).cloned().unwrap_or_default() }; + *per_channel = [ink(planes[0]), ink(planes[1]), ink(planes[2])]; + *black = Vec::new(); + *space = ToneSpace::Rgb; + } + _ => {} + } +} + +fn stand_in_effect(e: &mut Effect, planes: Planes) { + let cc = |c: &mut Color| *c = stand_in_color(c, planes); + let paint = |p: &mut FxPaint| match p { + FxPaint::Color(c) => cc(c), + FxPaint::Gradient(g) => g.stops.iter_mut().for_each(|s| cc(&mut s.1)), + FxPaint::Pattern { .. } => {} + }; + match e { + Effect::DropShadow(s) | Effect::InnerShadow(s) => cc(&mut s.color), + Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint(&mut g.paint), + Effect::Stroke(s) => paint(&mut s.paint), + Effect::ColorOverlay { color, .. } => cc(color), + Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)), + Effect::Satin(s) => cc(&mut s.color), + Effect::BevelEmboss(b) => { + cc(&mut b.highlight_color); + cc(&mut b.shadow_color); + } + Effect::PatternOverlay { .. } => {} + } +} + +fn stand_in_layer(l: &mut Layer, planes: Planes, depth: SampleType, canvas: Rect) { + // The compositor never draws a hidden layer (nor what is clipped to it): no stand-in needed. + if !l.visible { + return; + } + let cache = |c: &mut Option| { + if let Some(s) = c { + *s = stand_in_surface(s, planes); + } + }; + let frame = crate::fill_frame(l, canvas); + // A fill covers the canvas and beyond, where effects near its edges reach. + let reach = canvas.inflate(crate::effects::margin(l).saturating_add(1)); + match &mut l.content { + LayerContent::Raster(s) => *s = stand_in_surface(s, planes), + LayerContent::Text(t) => cache(&mut t.cache), + LayerContent::Shape(sh) => cache(&mut sh.cache), + LayerContent::Smart(sm) => cache(&mut sm.cache), + LayerContent::Fill(f) => { + // The cache stays valid: its fill is changed the same way. + if let Some(c) = l.fill_cache.as_mut().filter(|c| c.fill == *f) { + c.surface = stand_in_surface(&c.surface, planes); + stand_in_fill(&mut c.fill, planes, depth); + } else if matches!(f, Fill::Gradient { .. }) + && let Some(s) = crate::gradient_fill::render_cmyk_fill(f, reach, frame, depth) + { + // A gradient is the compositor's own CMYK ramp (an RGB ramp of the stand-in + // colours would interpolate differently), given as the fill's rendered pixels. + let mut fill = f.clone(); + stand_in_fill(&mut fill, planes, depth); + l.fill_cache = Some(photocraft_doc::FillCache { fill, surface: stand_in_surface(&s, planes) }); + } + stand_in_fill(f, planes, depth); + } + LayerContent::Adjustment(a) => stand_in_adjustment(a, planes), + LayerContent::Group(g) => g.children.iter_mut().for_each(|c| stand_in_layer(c, planes, depth, canvas)), + } + l.effects.items.iter_mut().for_each(|e| stand_in_effect(e, planes)); +} + +/// `doc` as an RGB document carrying the `planes` inks' brightness. +/// Colours given in RGB or Lab become ink through the document's own CMYK profile, as on the +/// exact ink path. +pub(super) fn stand_in(doc: &Document, planes: Planes) -> Document { + let mut d = doc.clone(); + d.mode = ColorMode::Rgb; + d.icc_profile = None; + let space = crate::cmyk_space(doc); + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + d.layers.iter_mut().for_each(|l| stand_in_layer(l, planes, doc.depth, doc.bounds())); + }); + d +} + +/// A stand-in document rendered over `rect` by the RGB compositor, blends clipped to 0..1. +struct StandIn<'a> { + doc: &'a Document, + cx: Ctx<'a>, +} + +impl<'a> StandIn<'a> { + /// Ready to render `rect` (effect maps built up front, as the RGB compositor does). + fn prepare(&self, rect: Rect) { + self.cx.set_fx_clip(rect); + crate::prepare_effects(&self.doc.layers, rect, &self.cx, |f| crate::in_blend_space(false, false, f)); + } + + fn tile(&self, rect: Rect) -> Buffer { + crate::in_blend_space(false, false, || { + let mut b = Buffer::transparent(rect); + crate::composite_stack_at(&self.doc.layers, &mut b, &self.cx, advanced::Scope::ROOT); + b + }) + } +} + +/// The CMY and K stand-ins' tiles as one CMYK tile. +fn combine(cmy: &Buffer, k: &Buffer) -> CmykBuffer { + let ink = |v: f32| if v.is_finite() { (1.0 - v).clamp(0.0, 1.0) } else { 0.0 }; + let px = cmy + .px + .iter() + .zip(&k.px) + .map(|(p, q)| { + let alpha = clean(p[3]).clamp(0.0, 1.0); + if alpha <= 0.0 { [0.0; 5] } else { [ink(p[0]), ink(p[1]), ink(p[2]), ink(q[0]), alpha] } + }) + .collect(); + CmykBuffer { rect: cmy.rect, px } +} + +/// Renders `doc` (which [`supports`]) band by band through the two stand-ins. +pub(super) fn render_bands(doc: &Document, rect: Rect, band_rows: i32, tile: i32, mut sink: impl FnMut(CmykBuffer) -> Result<(), E>) -> Result<(), E> { + let (cmy, k) = (stand_in(doc, CMY), stand_in(doc, K)); + let no_patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); + let a = StandIn { doc: &cmy, cx: Ctx::for_doc(&cmy, &no_patterns) }; + let b = StandIn { doc: &k, cx: Ctx::for_doc(&k, &no_patterns) }; + let rows = crate::band_rows_for(rect.width(), band_rows); + let mut y = rect.y0; + while y < rect.y1 { + let y1 = y.saturating_add(rows).min(rect.y1); + let band = Rect::new(rect.x0, y, rect.x1, y1); + a.prepare(band); + b.prepare(band); + sink(super::render_with(band, tile, |t| combine(&a.tile(t), &b.tile(t))))?; + y = y1; + } + Ok(()) +} + +/// `doc`'s `planes` stand-in rendered over `rect` (tests compare plane orders). +#[cfg(test)] +pub(super) fn render_planes(doc: &Document, rect: Rect, planes: Planes) -> Buffer { + let d = stand_in(doc, planes); + let no_patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); + let s = StandIn { doc: &d, cx: Ctx::for_doc(&d, &no_patterns) }; + s.prepare(rect); + s.tile(rect) +} + +#[cfg(test)] +mod batch_tests { + use super::*; + + /// A surface over many tiles converts alike one tile at a time or all at once, and a hidden + /// layer is left unconverted (the compositor never draws it). + #[test] + fn stand_ins_convert_in_batches_and_skip_hidden_layers() { + let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true); + let mut s = Surface::with_default(fmt, &[0.0; 5]); + let size = photocraft_geom::TILE_SIZE * 3 + 7; + s.fill_rect(Rect::new(0, 0, size, size / 2), &[0.2, 0.4, 0.6, 0.1, 1.0]); + s.fill_rect(Rect::new(5, size / 3, size, size), &[0.0, 0.9, 0.0, 0.5, 0.5]); + assert!(s.tiles().count() > 4); + let bounds = Rect::new(0, 0, size, size); + let read = |s: &Surface| { + let mut v = Vec::new(); + s.read_region_into(bounds, &mut v); + v + }; + for planes in [CMY, K] { + let whole = stand_in_surface_in(&s, planes, u64::MAX); + assert_eq!(read(&stand_in_surface_in(&s, planes, 1)), read(&whole)); + assert_eq!(read(&stand_in_surface(&s, planes)), read(&whole)); + } + let mut d = Document::new("d", photocraft_geom::Size::new(4, 4), ColorMode::Cmyk, SampleType::U8); + let mut hidden = Layer::raster("hidden", fmt); + hidden.visible = false; + d.layers.extend([Layer::raster("shown", fmt), hidden]); + let st = stand_in(&d, CMY); + let mode = |l: &Layer| l.surface().map(|s| s.format().mode); + assert_eq!(st.layers.iter().map(mode).collect::>(), [Some(ColorMode::Rgb), Some(ColorMode::Cmyk)]); + } +} diff --git a/crates/compose/src/cmyk_tests.rs b/crates/compose/src/cmyk_tests.rs new file mode 100644 index 0000000000..3e28fccd7e --- /dev/null +++ b/crates/compose/src/cmyk_tests.rs @@ -0,0 +1,1800 @@ +use photocraft_color::blend::BlendMode; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Adjustment, Document, Knockout, Layer, LayerContent, LayerMask}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::Surface; +use proptest::prelude::*; + +use super::*; + +const CMYKA8: PixelFormat = PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::U8, alpha: true }; +const CMYK8: PixelFormat = PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::U8, alpha: false }; + +fn doc(w: u32, h: u32) -> Document { + Document::new("c", Size::new(w, h), ColorMode::Cmyk, SampleType::U8) +} + +fn layer(rects: &[(Rect, Cmyka)]) -> Layer { + let mut l = Layer::raster("l", CMYKA8); + for (r, c) in rects { + if let Some(s) = l.surface_mut() { + s.fill_rect(*r, c); + } + } + l +} + +/// An opaque layer without alpha (as a flattened Background is). +fn paper(w: i32, h: i32, c: [f32; 4]) -> Layer { + let mut l = Layer::raster("bg", CMYK8); + if let Some(s) = l.surface_mut() { + s.fill_rect(Rect::new(0, 0, w, h), &c); + } + l +} + +fn mask(rects: &[(Rect, f32)], default: f32, density: f32, enabled: bool) -> LayerMask { + let mut surface = Surface::with_default(PixelFormat::GRAY8, &[default]); + for (r, v) in rects { + surface.fill_rect(*r, &[*v]); + } + LayerMask { surface, enabled, linked: true, density, feather: 0.0 } +} + +/// An 8-bit sample as stored. +fn q(v: f32) -> f32 { + (v.clamp(0.0, 1.0) * 255.0).round() / 255.0 +} + +// ---- an independent per-pixel reference ------------------------------------------------------- + +/// Porter-Duff source-over with straight alpha, written out separately from the compositor. +fn over(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka { + let sa = s[4] * opacity; + let a = sa + b[4] * (1.0 - sa); + if sa <= 0.0 { + return b; + } + if a <= 0.0 { + return [0.0; 5]; + } + let mut o = [0.0; 5]; + for i in 0..4 { + o[i] = (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a; + } + o[4] = a; + o +} + +fn ref_mask(l: &Layer, x: i32, y: i32) -> f32 { + l.mask.as_ref().map_or(1.0, |m| if m.enabled { 1.0 - m.density * (1.0 - m.surface.sample_channel(x, y, 0)) } else { 1.0 }) +} + +fn ref_own(l: &Layer, x: i32, y: i32) -> Cmyka { + let s = match &l.content { + LayerContent::Fill(f) => match &l.fill_cache { + Some(c) if c.fill == *f => Some(&c.surface), + _ => { + // A solid colour's inks and alpha, stored at the (8-bit) document's levels. + let photocraft_doc::Fill::Solid(color) = f else { return [0.0; 5] }; + let c = color.in_mode(ColorMode::Cmyk); + return [q(c.c[0]), q(c.c[1]), q(c.c[2]), q(c.c[3]), q(color.alpha) * ref_mask(l, x, y)]; + } + }, + _ => l.surface(), + }; + let Some(s) = s else { return [0.0; 5] }; + let p = s.pixel(x, y); + let a = if s.format().alpha { p[4] } else { 1.0 }; + [p[0], p[1], p[2], p[3], a * ref_mask(l, x, y)] +} + +/// Source-over with coverage mixed in the type gamma, on brightness (1 − ink), worked out from +/// the colour transfer functions directly. +fn over_text(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka { + let gamma = crate::psblend::text_gamma(); + let sa = s[4] * opacity; + if sa <= 0.0 { + return b; + } + let a = sa + b[4] * (1.0 - sa); + if a <= 0.0 { + return [0.0; 5]; + } + let enc = |ink: f32| photocraft_color::convert::srgb_to_linear((1.0 - ink).max(0.0)).powf(1.0 / gamma); + let dec = |v: f32| 1.0 - photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma)); + let mut o = [0.0; 5]; + for i in 0..4 { + // Mixing an ink with itself (or with nothing) leaves it exactly as it is. + o[i] = if b[4] <= 0.0 || b[i] == s[i] { s[i] } else { dec((1.0 - sa) * b[4] / a * enc(b[i]) + sa / a * enc(s[i])) }; + } + o[4] = a; + o +} + +/// The blended ink of one channel: the blend function on brightness, back to ink. +fn ref_blend(mode: BlendMode, b: f32, s: f32) -> f32 { + 1.0 - crate::psblend::blend_rgb(mode, [1.0 - b; 3], [1.0 - s; 3])[0] +} + +/// The general compositing formula (W3C / PDF), written out on each ink. +fn over_mode(mode: BlendMode, b: Cmyka, s: Cmyka, opacity: f32, text: bool) -> Cmyka { + if mode == BlendMode::Normal { + return if text { over_text(b, s, opacity) } else { over(b, s, opacity) }; + } + let sa = s[4] * opacity; + if sa <= 0.0 { + return b; + } + let a = sa + b[4] * (1.0 - sa); + if a <= 0.0 { + return [0.0; 5]; + } + let gamma = if text { crate::psblend::text_gamma() } else { 1.0 }; + let enc = |ink: f32| photocraft_color::convert::srgb_to_linear((1.0 - ink).max(0.0)).powf(1.0 / gamma); + let dec = |v: f32| 1.0 - photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma)); + let (wb, ws, wbs) = ((1.0 - sa) * b[4] / a, (1.0 - b[4]) * sa / a, sa * b[4] / a); + let mut o = [0.0; 5]; + for i in 0..4 { + let m = ref_blend(mode, b[i], s[i]); + o[i] = if text { dec(wb * enc(b[i]) + ws * enc(s[i]) + wbs * enc(m)) } else { wb * b[i] + ws * s[i] + wbs * m }; + } + o[4] = a; + o +} + +/// `l` onto the backdrop in its blend mode (Dissolve: every pixel shown fully or not at all). +fn over_layer(l: &Layer, b: Cmyka, s: Cmyka, opacity: f32, at: Option<(i32, i32)>) -> Cmyka { + if let (BlendMode::Dissolve, Some((x, y))) = (l.blend, at) { + let shown = crate::dissolve_noise(x, y) < s[4] * opacity; + return over(b, [s[0], s[1], s[2], s[3], if shown { 1.0 } else { 0.0 }], 1.0); + } + over_mode(l.blend, b, s, opacity, matches!(l.content, LayerContent::Text(_))) +} + +/// Photoshop's special eight below 100% Fill, written out on each ink: the source pulled toward +/// the mode's neutral colour is blended at full strength (`psblend::blend_rgb_fill`, on +/// brightness), Opacity is its coverage over the backdrop, Fill × Opacity over nothing. +fn over_special_fill(l: &Layer, b: Cmyka, s: Cmyka) -> Cmyka { + let f = l.fill_opacity.clamp(0.0, 1.0); + let over = s[4] * l.opacity; + if over <= 0.0 { + return b; + } + let a = b[4] + (1.0 - b[4]) * over * f; + if a <= 0.0 { + return [0.0; 5]; + } + let text = matches!(l.content, LayerContent::Text(_)); + let gamma = crate::psblend::text_gamma(); + let enc = |v: f32| if text { photocraft_color::convert::srgb_to_linear(v.max(0.0)).powf(1.0 / gamma) } else { v }; + let dec = |v: f32| if text { photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma)) } else { v }; + let (wb, wbs, ws) = (b[4] * (1.0 - over) / a, b[4] * over / a, (1.0 - b[4]) * over * f / a); + let mut o = [0.0; 5]; + for i in 0..4 { + let (bb, sb) = (1.0 - b[i], 1.0 - s[i]); + let m = crate::psblend::blend_rgb_fill(l.blend, [bb; 3], [sb; 3], f)[0]; + o[i] = 1.0 - dec(wb * enc(bb) + wbs * enc(m) + ws * enc(sb)); + } + o[4] = a; + o +} + +/// An adjustment on one pixel: each ink's brightness through the channel's table (Levels and +/// Curves: the compositor's tables for an 8-bit document, interpolated here), Invert and +/// Posterize written out. +fn ref_adjust(adj: &Adjustment, p: Cmyka) -> Cmyka { + if p[4] <= 0.0 { + return p; + } + let luts = crate::adjust::tone_luts_depth(adj, Some(SampleType::U8)); + let mut o = p; + for c in 0..4 { + let v = 1.0 - p[c]; + let r = match adj { + Adjustment::Invert => 1.0 - v, + Adjustment::Posterize { levels } => { + let n = (*levels).clamp(2, 255) as f32; + let bin = ((v * 255.0).round() * n / 256.0).floor().min(n - 1.0); + (bin * 255.0 / (n - 1.0)).floor() / 255.0 + } + _ => { + let t = &luts[c]; + let x = v.clamp(0.0, 1.0) * (t.len() - 1) as f32; + let i = x.floor() as usize; + let j = (i + 1).min(t.len() - 1); + t[i] + (t[j] - t[i]) * (x - i as f32) + } + }; + o[c] = 1.0 - r; + } + o +} + +/// An adjustment layer `l` (adjusting `p`, then `clipped` onto that) mixed into `p` in its blend +/// mode by its opacity and mask; coverage stays. +fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32, y: i32) -> Cmyka { + let mut a = ref_adjust(adj, p); + if p[4] > 0.0 { + a = ref_clip(clipped, a, x, y); + } + let k = l.opacity * l.fill_opacity * ref_mask(l, x, y); + if l.blend == BlendMode::Dissolve { + // Opacity and mask are the chance the pixel takes the adjustment fully. + return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p }; + } + let mut o = p; + for c in 0..4 { + o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k; + } + o +} + +/// `clipped` onto `content` (blending as if it were opaque; its coverage stays). +fn ref_clip(clipped: &[Layer], mut content: Cmyka, x: i32, y: i32) -> Cmyka { + for c in clipped.iter().filter(|c| c.visible) { + if let LayerContent::Adjustment(adj) = &c.content { + content = ref_adjusted(c, adj, &[], content, x, y); + } else if content[4] > 0.0 { + let a = content[4]; + let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, Some((x, y))); + content = [r[0], r[1], r[2], r[3], a]; + } + } + content +} + +/// Rounding to an 8-bit document's levels: stored channel brightness, and coverage. +fn ref_round(p: Cmyka) -> Cmyka { + let r = |v: f32| (v * 255.0 + 0.5).floor() / 255.0; + [1.0 - r(1.0 - p[0]), 1.0 - r(1.0 - p[1]), 1.0 - r(1.0 - p[2]), 1.0 - r(1.0 - p[3]), r(p[4])] +} + +fn ref_stack(layers: &[Layer], x: i32, y: i32, mut b: Cmyka) -> Cmyka { + let mut i = 0; + while i < layers.len() { + let base = &layers[i]; + let mut j = i + 1; + while j < layers.len() && layers[j].clipped && !base.clipped { + j += 1; + } + if base.visible { + b = ref_layer(base, &layers[i + 1..j], x, y, b); + if matches!(base.content, LayerContent::Adjustment(_)) { + b = ref_round(b); + } + } + i = j; + } + b +} + +fn ref_layer(l: &Layer, clipped: &[Layer], x: i32, y: i32, b: Cmyka) -> Cmyka { + let op = l.opacity * l.fill_opacity; + let mut content = match &l.content { + LayerContent::Group(g) if l.blend == BlendMode::PassThrough && l.fill_opacity >= 1.0 => { + let r = ref_stack(&g.children, x, y, b); + if op >= 1.0 && l.mask.is_none() { + return r; + } + let k = op * ref_mask(l, x, y); + let (wa, wb) = (b[4] * (1.0 - k), r[4] * k); + let a = wa + wb; + if a <= 0.0 { + return [0.0; 5]; + } + let mut o = [0.0; 5]; + for c in 0..4 { + o[c] = (b[c] * wa + r[c] * wb) / a; + } + o[4] = a; + return o; + } + LayerContent::Adjustment(adj) => return ref_adjusted(l, adj, clipped, b, x, y), + LayerContent::Group(g) => { + let mut c = ref_stack(&g.children, x, y, [0.0; 5]); + c[4] *= ref_mask(l, x, y); + c + } + _ => ref_own(l, x, y), + }; + content = ref_clip(clipped, content, x, y); + // The special eight below 100% Fill, a layer's own blend and an isolated group's alike. + if l.fill_opacity < 1.0 && crate::psblend::fill_is_special(l.blend) { + return over_special_fill(l, b, content); + } + over_layer(l, b, content, op, Some((x, y))) +} + +fn assert_matches_reference(d: &Document) { + let rect = Rect::new(-1, -1, d.size.width as i32 + 1, d.size.height as i32 + 1); + let Some(got) = render_cmyk(d, rect) else { panic!("supported") }; + let rgb = crate::render(d, rect); + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = ((y - rect.y0) * rect.width() as i32 + (x - rect.x0)) as usize; + let (g, want) = (got.px[i], ref_stack(&d.layers, x, y, [0.0; 5])); + assert!(g.iter().all(|v| v.is_finite()), "({x},{y}) {g:?}"); + // Ink only means something where there is coverage. + let close = |a: f32, b: f32| (a - b).abs() <= 1e-5; + assert!(close(g[4], want[4]), "alpha ({x},{y}): {g:?} vs reference {want:?}"); + if want[4] > 1e-4 { + assert!((0..4).all(|c| close(g[c], want[c])), "ink ({x},{y}): {g:?} vs reference {want:?}"); + } + // Coverage doesn't depend on the colour model: the RGB compositor agrees. + assert!((g[4] - rgb.px[i][3]).abs() <= 1e-5, "alpha vs RGB ({x},{y}): {g:?} vs {:?}", rgb.px[i]); + } + } +} + +// ---- directed tests --------------------------------------------------------------------------- + +#[test] +fn opaque_pixels_keep_their_exact_inks() { + let mut d = doc(6, 4); + d.layers.push(paper(6, 4, [0.0, 0.0, 0.0, 0.0])); + // K-only black "type", a rich four-colour patch, then a half-covered patch on top. + d.layers.push(layer(&[(Rect::new(0, 0, 3, 4), [0.0, 0.0, 0.0, 1.0, 1.0])])); + d.layers.push(layer(&[(Rect::new(3, 0, 6, 2), [0.2, 0.4, 0.6, 0.1, 1.0])])); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + let at = |x: i32, y: i32| got.px[(y * 6 + x) as usize]; + assert_eq!(at(1, 1), [0.0, 0.0, 0.0, 1.0, 1.0], "K stays K-only"); + assert_eq!(at(4, 1), [q(0.2), q(0.4), q(0.6), q(0.1), 1.0], "every ink kept as stored"); + assert_eq!(at(4, 3), [0.0, 0.0, 0.0, 0.0, 1.0], "paper: no ink"); +} + +#[test] +fn partial_coverage_mixes_ink_values() { + let mut d = doc(2, 1); + d.layers.push(paper(2, 1, [0.0, 0.0, 0.0, 0.0])); + let mut l = layer(&[(Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0, 1.0])]); + l.opacity = 0.5; + l.fill_opacity = 0.5; + d.layers.push(l); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + // 25% coverage of K100 over paper: K25 and no other ink. + assert!((got.px[0][3] - 0.25).abs() < 1e-6 && got.px[0][..3] == [0.0; 3], "{:?}", got.px[0]); +} + +#[test] +fn masks_groups_and_clipping_match_the_reference() { + let mut d = doc(8, 6); + d.layers.push(paper(8, 6, [0.1, 0.0, 0.0, 0.0])); + let mut base = layer(&[(Rect::new(1, 1, 7, 5), [0.0, 0.5, 0.0, 0.0, 0.8])]); + base.mask = Some(mask(&[(Rect::new(0, 0, 4, 6), 0.0)], 1.0, 0.75, true)); + let mut clip = layer(&[(Rect::new(0, 0, 8, 3), [0.0, 0.0, 1.0, 0.2, 1.0])]); + clip.clipped = true; + clip.opacity = 0.6; + let mut iso = Layer::group("iso", vec![layer(&[(Rect::new(2, 0, 5, 6), [0.3, 0.3, 0.3, 0.3, 0.5])])]); + iso.blend = BlendMode::Normal; + iso.opacity = 0.7; + iso.mask = Some(mask(&[(Rect::new(0, 3, 8, 6), 0.25)], 1.0, 1.0, true)); + let mut pass = Layer::group("pass", vec![layer(&[(Rect::new(5, 2, 8, 6), [1.0, 0.0, 0.0, 0.0, 1.0])])]); + pass.opacity = 0.5; + pass.mask = Some(mask(&[(Rect::new(6, 0, 8, 6), 0.0)], 1.0, 1.0, true)); + d.layers.extend([base, clip, iso, pass]); + assert_matches_reference(&d); +} + +/// An isolated group in one of the special eight below 100% Fill blends with Fill apart from +/// opacity, as the compositor blends it (the split route runs the compositor on stand-ins). +#[test] +fn isolated_groups_keep_the_special_fill_rule() { + for mode in [BlendMode::Difference, BlendMode::LinearBurn, BlendMode::LinearDodge, BlendMode::LinearLight, BlendMode::ColorDodge] { + assert!(crate::psblend::fill_is_special(mode), "{mode:?}"); + let mut d = doc(4, 2); + d.layers.push(paper(4, 2, [0.2, 0.5, 0.1, 0.1])); + let mut g = Layer::group("iso", vec![layer(&[(Rect::new(0, 0, 3, 2), [0.6, 0.1, 0.4, 0.2, 0.8])])]); + g.blend = mode; + g.opacity = 0.9; + g.fill_opacity = 0.4; + d.layers.push(g); + assert_matches_reference(&d); + assert_routes_agree(&d); + } +} + +#[test] +fn unsupported_documents_fall_back() { + let simple = || { + let mut d = doc(2, 2); + d.layers.push(layer(&[(Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0, 1.0])])); + d + }; + assert!(supports(&simple())); + let check = |what: &str, f: &dyn Fn(&mut Layer)| { + let mut d = simple(); + let mut l = layer(&[(Rect::new(0, 0, 1, 1), [0.5; 5])]); + f(&mut l); + d.layers.push(l); + assert!(!supports(&d), "{what}"); + assert!(render_cmyk(&d, d.bounds()).is_none(), "{what}"); + // An invisible layer doesn't matter. + if let Some(l) = d.layers.last_mut() { + l.visible = false; + } + assert!(supports(&d), "hidden {what}"); + }; + check("hue", &|l| l.blend = BlendMode::Hue); + check("luminosity", &|l| l.blend = BlendMode::Luminosity); + check("lighter colour", &|l| l.blend = BlendMode::LighterColor); + check("channels", &|l| l.excluded_channels = 1); + check("nan opacity", &|l| l.opacity = f32::NAN); + // Adjustments that weigh the channels together, or whose records name RGB channels. + check("threshold", &|l| l.content = LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 })); + check("rgb levels", &|l| l.content = LayerContent::Adjustment(rgb_levels())); + check("invert in colour mode", &|l| { + l.content = LayerContent::Adjustment(Adjustment::Invert); + l.blend = BlendMode::Color; + }); + check("rgb pixels", &|l| l.content = LayerContent::Raster(Surface::new(PixelFormat::RGBA8))); + // Not a CMYK document. + let mut rgb = simple(); + rgb.mode = ColorMode::Rgb; + assert!(!supports(&rgb)); + // Too deep: refused without recursing through every level. + let mut deep = layer(&[(Rect::new(0, 0, 1, 1), [0.5; 5])]); + for _ in 0..(MAX_DEPTH + 8) { + deep = Layer::group("g", vec![deep]); + } + let mut d = simple(); + d.layers.push(deep); + assert!(!supports(&d) && !renders_inks(&d)); + // Knockouts take the ink-by-ink route through the RGB compositor instead. + let mut d = simple(); + let mut l = layer(&[(Rect::new(0, 0, 1, 1), [0.5; 5])]); + l.advanced.knockout = Knockout::Shallow; + d.layers.push(l); + assert!(!supports(&d) && split::supports(&d)); + assert!(render_cmyk(&d, d.bounds()).is_some()); +} + +#[test] +fn non_finite_samples_never_reach_the_output() { + let fmt = PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::F32, alpha: true }; + let mut d = Document::new("f", Size::new(4, 1), ColorMode::Cmyk, SampleType::F32); + let mut l = Layer::raster("bad", fmt); + if let Some(s) = l.surface_mut() { + s.write_region( + Rect::new(0, 0, 4, 1), + &[ + f32::NAN, + 0.0, + 0.0, + 0.0, + 1.0, // + 0.0, + f32::INFINITY, + 0.0, + 0.0, + 1.0, // + 0.0, + 0.0, + f32::NEG_INFINITY, + 0.0, + f32::NAN, // + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + ], + ); + } + d.layers.push(l); + let got = render_cmyk(&d, Rect::new(-2, -2, 6, 3)).unwrap_or_else(|| panic!("supported")); + assert!(got.px.iter().flatten().all(|v| v.is_finite()), "{:?}", got.px); +} + +#[test] +fn empty_and_off_canvas_rects() { + let mut d = doc(4, 4); + d.layers.push(layer(&[(Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0, 1.0])])); + assert!(render_cmyk(&d, Rect::new(2, 2, 2, 2)).is_some_and(|b| b.px.is_empty())); + let far = render_cmyk(&d, Rect::new(1000, 1000, 1003, 1002)).unwrap_or_else(|| panic!("supported")); + assert_eq!(far.px, vec![[0.0; 5]; 6]); +} + +#[test] +fn bands_equal_the_whole_render() { + let mut d = doc(300, 700); + d.layers.push(paper(300, 700, [0.0, 0.1, 0.0, 0.0])); + d.layers.push(layer(&[(Rect::new(10, 20, 290, 690), [0.4, 0.0, 0.2, 0.9, 0.6])])); + let whole = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + let mut joined = Vec::new(); + let r = render_cmyk_bands(&d, d.bounds(), 256, |b: CmykBuffer| -> Result<(), ()> { + joined.extend(b.px); + Ok(()) + }); + assert_eq!(r, Some(Ok(()))); + assert_eq!(joined, whole.px); +} + +// ---- random documents ------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct Spec { + w: i32, + h: i32, + layers: Vec, +} + +#[derive(Debug, Clone)] +enum LayerSpec { + Pixels { kind: Kind, blend: BlendMode, fills: Vec<(Rect, Cmyka)>, opacity: f32, fill: f32, clipped: bool, visible: bool, mask: Option }, + Group { children: Vec, pass_through: bool, blend: BlendMode, opacity: f32, fill: f32, mask: Option }, +} + +/// What kind of layer a [`LayerSpec::Pixels`] becomes: its pixels as a pixel layer, as a type or +/// shape layer's rendered pixels, or a solid colour fill in the first fill's colour. +#[derive(Debug, Clone)] +enum Kind { + Raster, + Text, + Shape, + Solid, + /// An adjustment layer (its fills are unused). + Adjust(AdjSpec), +} + +/// The adjustments this path applies on ink values; `channel` 0..=3 is C, M, Y, K and 4 the +/// composite record. +#[derive(Debug, Clone)] +enum AdjSpec { + Invert, + Posterize(u32), + Levels { channel: usize, levels: [f32; 5] }, + Curves { channel: usize, points: Vec<(f32, f32)> }, +} + +#[derive(Debug, Clone)] +struct MaskSpec { + rects: Vec<(Rect, f32)>, + default: f32, + density: f32, + enabled: bool, +} + +/// Every blend mode this path composites natively, Normal most often. +fn blend_mode() -> impl Strategy { + prop_oneof![ + 6 => Just(BlendMode::Normal), + 1 => prop::sample::select(SEPARABLE.to_vec()), + ] +} + +fn adj_spec() -> impl Strategy { + prop_oneof![ + Just(AdjSpec::Invert), + (2u32..9).prop_map(AdjSpec::Posterize), + (0usize..5, 0.0f32..0.4, 0.6f32..=1.0, 0.3f32..3.0, 0.0f32..0.3, 0.7f32..=1.0) + .prop_map(|(channel, ib, iw, g, ob, ow)| AdjSpec::Levels { channel, levels: [ib, iw, g, ob, ow] }), + (0usize..5, prop::collection::vec((unit(), unit()), 2..5)).prop_map(|(channel, mut points)| { + points.sort_by(|a, b| a.0.total_cmp(&b.0)); + points.dedup_by(|a, b| (a.0 - b.0).abs() < 0.02); + AdjSpec::Curves { channel, points } + }), + ] +} + +fn make_adjustment(a: &AdjSpec) -> Adjustment { + use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace}; + match a { + AdjSpec::Invert => Adjustment::Invert, + AdjSpec::Posterize(levels) => Adjustment::Posterize { levels: *levels }, + AdjSpec::Levels { channel, levels: [in_black, in_white, gamma, out_black, out_white] } => { + let rec = LevelsChannel { in_black: *in_black, in_white: *in_white, gamma: *gamma, out_black: *out_black, out_white: *out_white }; + let pick = |c: usize| if c == *channel { rec.clone() } else { LevelsChannel::default() }; + Adjustment::Levels { master: pick(4), per_channel: [pick(0), pick(1), pick(2)], space: ToneSpace::Cmyk, black: pick(3) } + } + AdjSpec::Curves { channel, points } => { + let curve: Vec = points.iter().map(|(input, output)| CurvePoint { input: *input, output: *output }).collect(); + let pick = |c: usize| if c == *channel { curve.clone() } else { Vec::new() }; + Adjustment::Curves { master: pick(4), per_channel: [pick(0), pick(1), pick(2)], space: ToneSpace::Cmyk, black: pick(3), pencil: false } + } + } +} + +fn unit() -> impl Strategy { + prop_oneof![Just(0.0f32), Just(1.0f32), 0.0f32..=1.0] +} + +fn rect(w: i32, h: i32) -> impl Strategy { + (-1..w, -1..h, 1..w + 2, 1..h + 2).prop_map(|(x, y, rw, rh)| Rect::new(x, y, x + rw, y + rh)) +} + +fn mask_spec(w: i32, h: i32) -> impl Strategy> { + prop::option::of( + (prop::collection::vec((rect(w, h), unit()), 0..3), unit(), unit(), any::()).prop_map(|(rects, default, density, enabled)| MaskSpec { + rects, + default, + density, + enabled, + }), + ) +} + +fn pixels(w: i32, h: i32) -> impl Strategy { + ( + prop::collection::vec((rect(w, h), (unit(), unit(), unit(), unit(), unit())), 1..4), + unit(), + unit(), + any::(), + prop::bool::weighted(0.9), + mask_spec(w, h), + prop_oneof![ + 3 => prop_oneof![Just(Kind::Raster), Just(Kind::Text), Just(Kind::Shape), Just(Kind::Solid)], + 1 => adj_spec().prop_map(Kind::Adjust), + ], + blend_mode(), + ) + .prop_map(|(fills, opacity, fill, clipped, visible, mask, kind, blend)| LayerSpec::Pixels { + kind, + blend, + fills: fills.into_iter().map(|(r, (c, m, y, k, a))| (r, [c, m, y, k, a])).collect(), + opacity, + fill, + clipped, + visible, + mask, + }) +} + +fn layer_spec(w: i32, h: i32) -> impl Strategy { + pixels(w, h).prop_recursive(3, 12, 4, move |inner| { + (prop::collection::vec(inner, 1..4), any::(), unit(), prop_oneof![Just(1.0f32), unit()], mask_spec(w, h), blend_mode()) + .prop_map(move |(children, pass_through, opacity, fill, mask, blend)| LayerSpec::Group { children, pass_through, blend, opacity, fill, mask }) + }) +} + +fn spec() -> impl Strategy { + (2i32..9, 2i32..7).prop_flat_map(|(w, h)| prop::collection::vec(layer_spec(w, h), 1..6).prop_map(move |layers| Spec { w, h, layers })) +} + +fn build(s: &LayerSpec) -> Layer { + let make_mask = |m: &MaskSpec| mask(&m.rects, m.default, m.density, m.enabled); + match s { + LayerSpec::Pixels { kind, blend, fills, opacity, fill, clipped, visible, mask } => { + let pixels = layer(fills); + let mut l = match kind { + Kind::Raster => pixels, + Kind::Text => Layer::new("t", LayerContent::Text(photocraft_doc::TextLayer { cache: pixels.surface().cloned(), ..Default::default() })), + Kind::Shape => Layer::new("s", LayerContent::Shape(photocraft_doc::ShapeLayer { cache: pixels.surface().cloned(), ..Default::default() })), + Kind::Solid => { + let [c, m, y, k, a] = fills.first().map_or([0.0; 5], |f| f.1); + let color = photocraft_color::Color { mode: ColorMode::Cmyk, c: [c, m, y, k], alpha: a }; + Layer::new("f", LayerContent::Fill(photocraft_doc::Fill::Solid(color))) + } + Kind::Adjust(a) => Layer::new("a", LayerContent::Adjustment(make_adjustment(a))), + }; + (l.opacity, l.fill_opacity, l.clipped, l.visible) = (*opacity, *fill, *clipped, *visible); + l.blend = *blend; + l.mask = mask.as_ref().map(make_mask); + l + } + LayerSpec::Group { children, pass_through, blend, opacity, fill, mask } => { + let mut g = Layer::group("g", children.iter().map(build).collect()); + g.blend = if *pass_through { BlendMode::PassThrough } else { *blend }; + (g.opacity, g.fill_opacity) = (*opacity, *fill); + g.mask = mask.as_ref().map(make_mask); + g + } + } +} + +/// Clipped layers onto a pass-through group take the RGB compositor's isolated-rendering path, +/// which this path leaves alone: such specs are filtered out. +fn clips_onto_pass_through(layers: &[LayerSpec]) -> bool { + layers.windows(2).any(|w| { + matches!(w[0], LayerSpec::Group { pass_through: true, fill, .. } if fill >= 1.0) + && matches!(w[1], LayerSpec::Pixels { clipped: true, visible: true, .. }) + }) || layers.iter().any(|l| matches!(l, LayerSpec::Group { children, .. } if clips_onto_pass_through(children))) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })] + + /// Random stacks of pixel layers, masks, isolated and pass-through groups and clipping + /// groups: the native composite equals the independent per-pixel reference, and its + /// coverage equals the RGB compositor's. + #[test] + fn random_documents_match_the_reference(s in spec()) { + prop_assume!(!clips_onto_pass_through(&s.layers)); + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + prop_assume!(supports(&d)); + assert_matches_reference(&d); + } + + /// Tiling never changes the result (tiles render independently, in any order). + #[test] + fn tiling_is_invisible(s in spec(), tile in 1i32..9) { + prop_assume!(!clips_onto_pass_through(&s.layers)); + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + prop_assume!(supports(&d)); + let whole = render_cmyk_tiled(&d, d.bounds(), 1024); + prop_assert_eq!(render_cmyk_tiled(&d, d.bounds(), tile), whole); + } +} + +/// Where the composite is a single opaque layer's pixel (opaque, full opacity, binary masks), +/// the native ink, shown through the document's profile, is the colour the RGB compositor shows. +#[test] +fn opaque_results_look_the_same_as_the_rgb_compositor() { + let mut d = doc(8, 4); + d.layers.push(paper(8, 4, [0.0, 0.0, 0.0, 0.0])); + d.layers.push(layer(&[(Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0, 1.0]), (Rect::new(4, 0, 8, 2), [0.7, 0.1, 0.0, 0.0, 1.0])])); + let mut top = layer(&[(Rect::new(2, 1, 7, 4), [0.0, 0.9, 0.9, 0.0, 1.0])]); + top.mask = Some(mask(&[(Rect::new(5, 0, 8, 4), 0.0)], 1.0, 1.0, true)); + d.layers.push(top); + let native = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + let rgb = crate::render(&d, d.bounds()); + let space = crate::cmyk_space(&d); + let fmt = PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::F32, alpha: true }; + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + for (n, r) in native.px.iter().zip(&rgb.px) { + let shown = photocraft_raster::to_rgba(&fmt, n); + assert!((0..4).all(|c| (shown[c] - r[c]).abs() <= 1e-4), "native {n:?} shows {shown:?}, RGB compositor {r:?}"); + } + }); +} + +// ---- stage 2: type, shape, smart object and fill layers ---------------------------------------- + +fn text_layer(rects: &[(Rect, Cmyka)]) -> Layer { + let cache = layer(rects).surface().cloned(); + Layer::new("t", LayerContent::Text(photocraft_doc::TextLayer { cache, ..Default::default() })) +} + +fn cmyk_color(c: [f32; 4], alpha: f32) -> photocraft_color::Color { + photocraft_color::Color { mode: ColorMode::Cmyk, c, alpha } +} + +/// K-only type keeps K-only glyphs: fully covered pixels are exactly K100, and anti-aliased edges +/// mix on brightness in the type gamma, adding no cyan, magenta or yellow. +#[test] +fn type_interiors_are_exact_and_edges_add_no_other_ink() { + let mut d = doc(3, 1); + d.layers.push(paper(3, 1, [0.0; 4])); + d.layers.push(text_layer(&[(Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0, 1.0]), (Rect::new(1, 0, 2, 1), [0.0, 0.0, 0.0, 1.0, 0.5])])); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + assert_eq!(got.px[0], [0.0, 0.0, 0.0, 1.0, 1.0], "glyph interior"); + let edge = got.px[1]; + assert_eq!(edge[..3], [0.0; 3], "an edge adds no CMY: {edge:?}"); + let want = over_text([0.0, 0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0, q(0.5)], 1.0); + assert!((edge[3] - want[3]).abs() < 1e-6, "{edge:?} vs {want:?}"); + // The gamma lightens dark edges compared with plain mixing. + assert!(edge[3] < q(0.5), "{edge:?}"); + assert_eq!(got.px[2], [0.0, 0.0, 0.0, 0.0, 1.0], "paper"); +} + +#[test] +fn solid_and_rendered_fills_keep_their_inks() { + let mut d = doc(4, 1); + d.layers.push(paper(4, 1, [0.0; 4])); + let mut solid = Layer::new("f", LayerContent::Fill(photocraft_doc::Fill::Solid(cmyk_color([0.1, 0.2, 0.3, 0.4], 1.0)))); + solid.mask = Some(mask(&[(Rect::new(2, 0, 4, 1), 0.0)], 1.0, 1.0, true)); + d.layers.push(solid); + // A gradient fill carrying the pixels it was rendered to (as a PSD supplies them). + let gradient = photocraft_doc::Fill::gradient( + vec![(0.0, cmyk_color([1.0, 0.0, 0.0, 0.0], 1.0)), (1.0, cmyk_color([0.0, 0.0, 0.0, 1.0], 1.0))], + 0.0, + 1.0, + Default::default(), + false, + ); + let rendered = layer(&[(Rect::new(3, 0, 4, 1), [0.9, 0.0, 0.5, 0.0, 1.0])]); + let mut cached = Layer::new("g", LayerContent::Fill(gradient.clone())); + cached.fill_cache = rendered.surface().map(|s| photocraft_doc::FillCache { fill: gradient, surface: s.clone() }); + d.layers.push(cached); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + assert_eq!(got.px[0], [q(0.1), q(0.2), q(0.3), q(0.4), 1.0], "the colour's own inks, at the document's levels"); + assert_eq!(got.px[2], [0.0, 0.0, 0.0, 0.0, 1.0], "masked out: paper"); + assert_eq!(got.px[3], [q(0.9), 0.0, q(0.5), 0.0, 1.0], "the fill's rendered pixels"); + assert_matches_reference(&d); +} + +#[test] +fn stage_two_layers_that_stay_on_the_rgb_compositor() { + let base = || { + let mut d = doc(2, 2); + d.layers.push(paper(2, 2, [0.0; 4])); + d + }; + // A pattern fill without rendered pixels. + let mut d = base(); + let p = photocraft_doc::Fill::Pattern { name: "p".into(), scale: 1.0, id: "p".into(), angle: 0.0, link: true, phase: (0.0, 0.0) }; + d.layers.push(Layer::new("p", LayerContent::Fill(p))); + assert!(!supports(&d), "uncached pattern fill"); + // Type rendered in RGB (not the document's model). + let mut d = base(); + let mut rgb = Layer::raster("x", PixelFormat::RGBA8); + if let Some(s) = rgb.surface_mut() { + s.fill_rect(Rect::new(0, 0, 1, 1), &[0.0, 0.0, 0.0, 1.0]); + } + d.layers.push(Layer::new("t", LayerContent::Text(photocraft_doc::TextLayer { cache: rgb.surface().cloned(), ..Default::default() }))); + assert!(!supports(&d), "RGB type pixels"); + // A stroked shape with a clipped layer (its stroke goes above the clipped layers). + let mut d = base(); + let shape = photocraft_doc::ShapeLayer { stroke: Some(Default::default()), cache: layer(&[]).surface().cloned(), ..Default::default() }; + d.layers.push(Layer::new("s", LayerContent::Shape(shape))); + let mut clip = layer(&[(Rect::new(0, 0, 2, 2), [0.5; 5])]); + clip.clipped = true; + d.layers.push(clip); + assert!(!supports(&d), "stroked shape with clipped layers"); + if let Some(c) = d.layers.last_mut() { + c.visible = false; + } + assert!(supports(&d), "without a visible clipped layer the stroked shape is plain pixels"); +} + +/// A gradient fill without rendered pixels is the compositor's own CMYK ramp: between cyan and +/// black it holds only those two inks, pixel for pixel as the compositor renders it. +#[test] +fn uncached_gradient_fills_are_cmyk_ramps() { + let mut d = doc(8, 2); + let g = photocraft_doc::Fill::gradient( + vec![(0.0, cmyk_color([1.0, 0.0, 0.0, 0.0], 1.0)), (1.0, cmyk_color([0.0, 0.0, 0.0, 1.0], 1.0))], + 0.0, + 1.0, + Default::default(), + false, + ); + d.layers.push(Layer::new("g", LayerContent::Fill(g.clone()))); + assert!(supports(&d)); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + let frame = crate::fill_frame(&d.layers[0], d.bounds()); + let want = crate::gradient_fill::render_cmyk_fill(&g, d.bounds(), frame, SampleType::U8).unwrap_or_else(|| panic!("ramp")); + for (i, p) in got.px.iter().enumerate() { + let (x, y) = ((i % 8) as i32, (i / 8) as i32); + assert_eq!(p[1..3], [0.0, 0.0], "only cyan and black: {p:?}"); + assert_eq!(p.to_vec(), want.pixel(x, y), "({x},{y})"); + } + assert!(got.px[0][0] > got.px[7][0] && got.px[0][3] < got.px[7][3], "cyan to black"); +} + +// ---- stage 3: blend modes on ink values -------------------------------------------------------- + +const ALL_MODES: [BlendMode; 34] = [ + BlendMode::PassThrough, + BlendMode::Normal, + BlendMode::Dissolve, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::DarkerColor, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::LinearDodge, + BlendMode::LighterColor, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::VividLight, + BlendMode::LinearLight, + BlendMode::PinLight, + BlendMode::HardMix, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Subtract, + BlendMode::Divide, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + BlendMode::Reflect, + BlendMode::Glow, + BlendMode::Negation, + BlendMode::Xor, + BlendMode::PaintNetColorBurn, + BlendMode::PaintNetColorDodge, +]; + +/// The modes [`separable`] accepts, besides Normal. +const SEPARABLE: [BlendMode; 27] = [ + BlendMode::Dissolve, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::LinearDodge, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::VividLight, + BlendMode::LinearLight, + BlendMode::PinLight, + BlendMode::HardMix, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Subtract, + BlendMode::Divide, + BlendMode::Reflect, + BlendMode::Glow, + BlendMode::Negation, + BlendMode::Xor, + BlendMode::PaintNetColorBurn, + BlendMode::PaintNetColorDodge, + BlendMode::Normal, +]; + +/// Every mode treated as per-channel really is: a channel's result depends only on that +/// channel's values (checked on many inputs), so applying it to each ink is exact. The modes left +/// to the RGB compositor really do mix channels. +#[test] +fn separable_modes_are_channel_by_channel() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut r = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 40) as f32 / (1u64 << 24) as f32 + }; + for mode in ALL_MODES { + let mut mixes = false; + for _ in 0..2000 { + let (b, s) = ([r(), r(), r()], [r(), r(), r()]); + let (b2, s2) = ([b[0], r(), r()], [s[0], r(), r()]); + let (x, y) = (crate::psblend::blend_rgb(mode, b, s)[0], crate::psblend::blend_rgb(mode, b2, s2)[0]); + mixes |= (x - y).abs() > 1e-6 || x.is_nan() != y.is_nan(); + } + // Pass Through is a group behaviour (its pixel formula is Normal's); it's checked apart. + if mode != BlendMode::PassThrough { + assert_eq!(separable(mode), !mixes, "{mode:?}"); + } + assert_eq!(SEPARABLE.contains(&mode), separable(mode), "{mode:?} list"); + } +} + +/// Opaque layers over opaque paper in common modes give the inks worked out by hand. +#[test] +fn blend_modes_give_the_expected_inks() { + type Ink = fn(f32, f32) -> f32; + let cases: [(BlendMode, Ink); 7] = [ + (BlendMode::Multiply, |b, s| b + s - b * s), // darker: inks add up + (BlendMode::Screen, |b, s| b * s), // lighter: inks thin out + (BlendMode::Darken, f32::max), + (BlendMode::Lighten, f32::min), + (BlendMode::Difference, |b, s| 1.0 - (b - s).abs()), + (BlendMode::LinearBurn, |b, s| (b + s).min(1.0)), + (BlendMode::LinearDodge, |b, s| (b + s - 1.0).max(0.0)), + ]; + let inks = [0.0, 0.2, 0.5, 0.8, 1.0]; + for (mode, want) in cases { + for (bi, si) in inks.iter().flat_map(|b| inks.iter().map(move |s| (*b, *s))) { + let mut d = doc(1, 1); + d.layers.push(paper(1, 1, [bi, 0.0, bi, si])); + let mut l = layer(&[(Rect::new(0, 0, 1, 1), [si, si, 0.0, bi, 1.0])]); + l.blend = mode; + d.layers.push(l); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported"))[0]; + let (b, s) = (q(bi), q(si)); + let exp = [want(b, s), want(0.0, s), want(b, 0.0), want(s, b), 1.0]; + assert!(got.iter().zip(exp).all(|(g, e)| (g - e).abs() < 1e-5), "{mode:?} b={b} s={s}: {got:?} vs {exp:?}"); + } + } +} + +impl std::ops::Index for CmykBuffer { + type Output = Cmyka; + fn index(&self, i: usize) -> &Cmyka { + &self.px[i] + } +} + +/// Dissolve shows a pixel fully or not at all, with the RGB compositor's noise: coverage is +/// either the layer's full ink or untouched paper, and agrees pixel for pixel with the RGB one. +#[test] +fn dissolve_matches_the_rgb_compositors_pixels() { + let mut d = doc(16, 16); + d.layers.push(paper(16, 16, [0.0; 4])); + let mut l = layer(&[(Rect::new(0, 0, 16, 16), [0.0, 0.0, 0.0, 1.0, 0.4])]); + l.blend = BlendMode::Dissolve; + d.layers.push(l); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + let rgb = crate::render(&d, d.bounds()); + let mut shown = 0; + for (c, r) in got.px.iter().zip(&rgb.px) { + assert!(*c == [0.0, 0.0, 0.0, 1.0, 1.0] || *c == [0.0, 0.0, 0.0, 0.0, 1.0], "{c:?}"); + // Paper is white on screen; a shown pixel is the K100's dark colour. + assert_eq!(c[3] == 1.0, r[0] < 0.5, "{c:?} vs {r:?}"); + shown += usize::from(c[3] == 1.0); + } + assert!((60..150).contains(&shown), "about 40% of 256 shown: {shown}"); +} + +/// Photoshop's special eight: Fill below 100% is part of the blend, not coverage. Difference at +/// 50% Fill subtracts half the source's brightness, where 50% coverage would mix the full +/// difference in by half (the RGB compositor does the same per channel). An ink neither side +/// has comes out at 50%: the half-pulled source differs from the paper by half. +#[test] +fn fill_is_part_of_the_blend_for_the_special_eight() { + let mut d = doc(1, 1); + d.layers.push(paper(1, 1, [0.7, 0.0, 0.0, 0.0])); + let mut l = layer(&[(Rect::new(0, 0, 1, 1), [0.1, 0.0, 0.0, 0.0, 1.0])]); + l.blend = BlendMode::Difference; + l.fill_opacity = 0.5; + d.layers.push(l); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported"))[0]; + let inside = |bi: f32, si: f32| 1.0 - ((1.0 - bi) - 0.5 * (1.0 - si)).abs(); + let want = [inside(q(0.7), q(0.1)), inside(0.0, 0.0), inside(0.0, 0.0), inside(0.0, 0.0), 1.0]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} vs {want:?}"); + let (b, s) = (1.0 - q(0.7), 1.0 - q(0.1)); + let as_coverage = 1.0 - (0.5 * b + 0.5 * (b - s).abs()); + assert!((want[0] - as_coverage).abs() > 0.2, "the two readings differ"); +} + +// ---- stage 4: adjustment layers on ink values -------------------------------------------------- + +fn rgb_levels() -> Adjustment { + use photocraft_doc::adjust::{LevelsChannel, ToneSpace}; + let d = LevelsChannel::default; + Adjustment::Levels { master: LevelsChannel { in_white: 0.5, ..d() }, per_channel: [d(), d(), d()], space: ToneSpace::Rgb, black: d() } +} + +fn cmyk_levels(master: photocraft_doc::adjust::LevelsChannel, black: photocraft_doc::adjust::LevelsChannel) -> Adjustment { + use photocraft_doc::adjust::{LevelsChannel, ToneSpace}; + let d = LevelsChannel::default; + Adjustment::Levels { master, per_channel: [d(), d(), d()], space: ToneSpace::Cmyk, black } +} + +fn adjustment(a: Adjustment) -> Layer { + Layer::new("a", LayerContent::Adjustment(a)) +} + +fn render1(d: &Document, x: i32, y: i32) -> Cmyka { + let got = render_cmyk(d, d.bounds()).unwrap_or_else(|| panic!("supported")); + got.px[(y * d.size.width as i32 + x) as usize] +} + +fn near(a: Cmyka, b: Cmyka, tol: f32) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +/// Invert turns every ink into its opposite, black included (blank paper becomes four-colour +/// black); Posterize steps each ink on its own. +#[test] +fn invert_and_posterize_work_ink_by_ink() { + let mut d = doc(2, 1); + d.layers.push(paper(2, 1, [0.0; 4])); + d.layers.push(layer(&[(Rect::new(1, 0, 2, 1), [0.0, 0.0, 0.0, 1.0, 1.0])])); + d.layers.push(adjustment(Adjustment::Invert)); + assert_eq!(render1(&d, 0, 0), [1.0, 1.0, 1.0, 1.0, 1.0], "paper"); + assert_eq!(render1(&d, 1, 0), [1.0, 1.0, 1.0, 0.0, 1.0], "K-only black"); + let mut d = doc(1, 1); + d.layers.push(paper(1, 1, [0.2, 0.45, 0.7, 0.9])); + d.layers.push(adjustment(Adjustment::Posterize { levels: 2 })); + // Two levels: brightness below half goes dark (full ink), above half light (no ink). + assert_eq!(render1(&d, 0, 0), [0.0, 0.0, 1.0, 1.0, 1.0]); +} + +/// Levels' composite record changes every ink, the black record only K; channels a record +/// leaves alone keep their exact inks. +#[test] +fn levels_records_address_their_inks() { + use photocraft_doc::adjust::LevelsChannel; + let d0 = LevelsChannel::default; + // Composite: input white at 50% doubles each ink's brightness. + let mut d = doc(1, 1); + d.layers.push(paper(1, 1, [0.2, 0.4, 0.6, 0.1])); + d.layers.push(adjustment(cmyk_levels(LevelsChannel { in_white: 0.5, ..d0() }, d0()))); + assert!(near(render1(&d, 0, 0), [0.0, 0.0, 0.2, 0.0, 1.0], 1.0 / 255.0), "{:?}", render1(&d, 0, 0)); + // Black: output white at 50% leaves at least half K, and no other ink. + let mut d = doc(2, 1); + d.layers.push(paper(2, 1, [0.0; 4])); + d.layers.push(layer(&[(Rect::new(1, 0, 2, 1), [0.4, 0.0, 0.0, 0.0, 1.0])])); + d.layers.push(adjustment(cmyk_levels(d0(), LevelsChannel { out_white: 0.5, ..d0() }))); + let (blank, cyan) = (render1(&d, 0, 0), render1(&d, 1, 0)); + assert_eq!(&blank[..3], &[0.0; 3], "{blank:?}"); + assert!((blank[3] - 0.5).abs() <= 1.0 / 255.0, "{blank:?}"); + assert_eq!(cyan[0], q(0.4), "cyan untouched: {cyan:?}"); +} + +/// Opacity, masks, blend modes, clipping onto adjustments and adjustments clipped onto a layer, +/// inside isolated and pass-through groups, against the reference. +#[test] +fn adjustment_stacks_match_the_reference() { + use photocraft_doc::adjust::LevelsChannel; + let d0 = LevelsChannel::default; + let mut d = doc(8, 6); + d.layers.push(paper(8, 6, [0.05, 0.1, 0.0, 0.0])); + d.layers.push(layer(&[(Rect::new(0, 0, 5, 6), [0.0, 0.0, 0.0, 1.0, 1.0]), (Rect::new(5, 0, 8, 3), [0.6, 0.2, 0.1, 0.3, 0.7])])); + let mut inv = adjustment(Adjustment::Invert); + inv.opacity = 0.4; + inv.mask = Some(mask(&[(Rect::new(0, 0, 3, 6), 0.0)], 1.0, 0.8, true)); + let mut onto = layer(&[(Rect::new(2, 2, 8, 6), [0.0, 0.7, 0.0, 0.0, 1.0])]); + onto.clipped = true; + onto.blend = BlendMode::Multiply; + let mut base = layer(&[(Rect::new(1, 1, 7, 5), [0.1, 0.1, 0.5, 0.0, 0.9])]); + base.blend = BlendMode::Screen; + let mut lv = adjustment(cmyk_levels(LevelsChannel { gamma: 1.8, ..d0() }, LevelsChannel { in_black: 0.2, ..d0() })); + lv.clipped = true; + lv.blend = BlendMode::Overlay; + let mut pass = Layer::group("pass", vec![adjustment(Adjustment::Posterize { levels: 4 })]); + pass.opacity = 0.6; + let mut iso = Layer::group("iso", vec![layer(&[(Rect::new(0, 3, 8, 6), [0.5, 0.0, 0.5, 0.0, 0.5])]), adjustment(Adjustment::Invert)]); + iso.blend = BlendMode::Normal; + d.layers.extend([inv, onto, base, lv, pass, iso]); + assert_matches_reference(&d); + // The black record and the rest go the same way through the stand-ins. + assert_routes_agree(&d); +} + +/// In a 32-bit document (no rounding to levels), inks an adjustment leaves alone come out +/// bit for bit, at full strength and through blend modes that pick an input. +#[test] +fn untouched_inks_stay_bit_exact() { + use photocraft_doc::adjust::LevelsChannel; + let d0 = LevelsChannel::default; + let fmt = PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::F32, alpha: false }; + let inks = [0.123_456_7, 0.456_789_1, 0.789_123_4, 0.0]; + for blend in [BlendMode::Normal, BlendMode::Darken, BlendMode::Lighten] { + let mut d = Document::new("f", Size::new(1, 1), ColorMode::Cmyk, SampleType::F32); + let mut bg = Layer::raster("bg", fmt); + if let Some(s) = bg.surface_mut() { + s.fill_rect(Rect::new(0, 0, 1, 1), &inks); + } + d.layers.push(bg); + let mut a = adjustment(cmyk_levels(d0(), LevelsChannel { out_white: 0.5, ..d0() })); + a.blend = blend; + d.layers.push(a); + let got = render1(&d, 0, 0); + assert_eq!(&got[..3], &inks[..3], "{blend:?}"); + let want_k = if blend == BlendMode::Lighten { 0.0 } else { 0.5 }; + assert!((got[3] - want_k).abs() < 1e-6, "{blend:?}: {got:?}"); + } +} + +// ---- stage 4: layer effects, ink by ink through the RGB compositor ----------------------------- + +use photocraft_doc::effects::{ + Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Satin, Shadow, StrokeFx, StrokePosition, +}; + +fn ink_color(c: [f32; 4]) -> photocraft_color::Color { + photocraft_color::Color { mode: ColorMode::Cmyk, c, alpha: 1.0 } +} + +/// `d` over its canvas grown by a pixel, through the split route in 3-pixel tiles. +fn split_render(d: &Document) -> CmykBuffer { + let rect = Rect::new(-1, -1, d.size.width as i32 + 1, d.size.height as i32 + 1); + let mut out = CmykBuffer::transparent(rect); + let r = split::render_bands(d, rect, 0, 3, |b| { + super::place(&mut out, &b); + Ok::<(), ()>(()) + }); + assert!(r.is_ok()); + out +} + +#[derive(Debug, Clone)] +enum FxSpec { + Shadow { inner: bool, color: [f32; 4], blend: BlendMode, opacity: f32, distance: f32, size: f32, spread: f32, knocks_out: bool }, + Glow { inner: bool, color: [f32; 4], gradient: Option<[f32; 4]>, blend: BlendMode, opacity: f32, size: f32 }, + Stroke { color: [f32; 4], gradient: Option<[f32; 4]>, position: u8, size: f32, blend: BlendMode, opacity: f32 }, + Overlay { color: [f32; 4], blend: BlendMode, opacity: f32 }, + GradientOverlay { from: [f32; 4], to: [f32; 4], blend: BlendMode, opacity: f32 }, + Satin { color: [f32; 4], blend: BlendMode, opacity: f32, invert: bool }, + Bevel { style: u8, size: f32, up: bool, highlight: [f32; 4], shadow: [f32; 4] }, +} + +fn inks4() -> impl Strategy { + (unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| [c, m, y, k]) +} + +fn fx_spec() -> impl Strategy { + prop_oneof![ + (any::(), inks4(), blend_mode(), unit(), 0.0f32..3.0, 0.0f32..3.0, unit(), any::()).prop_map( + |(inner, color, blend, opacity, distance, size, spread, knocks_out)| FxSpec::Shadow { + inner, + color, + blend, + opacity, + distance, + size, + spread, + knocks_out + } + ), + (any::(), inks4(), prop::option::of(inks4()), blend_mode(), unit(), 0.0f32..3.0) + .prop_map(|(inner, color, gradient, blend, opacity, size)| FxSpec::Glow { inner, color, gradient, blend, opacity, size }), + (inks4(), prop::option::of(inks4()), 0u8..3, 1.0f32..3.0, blend_mode(), unit()) + .prop_map(|(color, gradient, position, size, blend, opacity)| FxSpec::Stroke { color, gradient, position, size, blend, opacity }), + (inks4(), blend_mode(), unit()).prop_map(|(color, blend, opacity)| FxSpec::Overlay { color, blend, opacity }), + (inks4(), inks4(), blend_mode(), unit()).prop_map(|(from, to, blend, opacity)| FxSpec::GradientOverlay { from, to, blend, opacity }), + (inks4(), blend_mode(), unit(), any::()).prop_map(|(color, blend, opacity, invert)| FxSpec::Satin { color, blend, opacity, invert }), + (0u8..5, 1.0f32..3.0, any::(), inks4(), inks4()).prop_map(|(style, size, up, highlight, shadow)| FxSpec::Bevel { + style, + size, + up, + highlight, + shadow + }), + ] +} + +fn make_effect(f: &FxSpec) -> Effect { + let paint = |c: &[f32; 4], g: &Option<[f32; 4]>| match g { + Some(to) => FxPaint::Gradient(Gradient { stops: vec![(0.0, ink_color(*c)), (1.0, ink_color(*to))], ..Default::default() }), + None => FxPaint::Color(ink_color(*c)), + }; + match f { + FxSpec::Shadow { inner, color, blend, opacity, distance, size, spread, knocks_out } => { + let s = Shadow { + common: FxCommon::new(*blend, *opacity), + color: ink_color(*color), + angle: 120.0, + use_global_light: false, + distance: *distance, + spread: *spread, + size: *size, + contour: Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: *knocks_out, + }; + if *inner { Effect::InnerShadow(s) } else { Effect::DropShadow(s) } + } + FxSpec::Glow { inner, color, gradient, blend, opacity, size } => { + let g = Glow { + common: FxCommon::new(*blend, *opacity), + paint: paint(color, gradient), + technique: GlowTechnique::Softer, + spread: 0.0, + size: *size, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source: GlowSource::Edge, + }; + if *inner { Effect::InnerGlow(g) } else { Effect::OuterGlow(g) } + } + FxSpec::Stroke { color, gradient, position, size, blend, opacity } => Effect::Stroke(StrokeFx { + common: FxCommon::new(*blend, *opacity), + size: *size, + position: [StrokePosition::Outside, StrokePosition::Inside, StrokePosition::Center][usize::from(*position) % 3], + paint: paint(color, gradient), + overprint: false, + }), + FxSpec::Overlay { color, blend, opacity } => Effect::ColorOverlay { common: FxCommon::new(*blend, *opacity), color: ink_color(*color) }, + FxSpec::GradientOverlay { from, to, blend, opacity } => Effect::GradientOverlay { + common: FxCommon::new(*blend, *opacity), + gradient: Gradient { stops: vec![(0.0, ink_color(*from)), (1.0, ink_color(*to))], ..Default::default() }, + dither: false, + interpolation: None, + }, + FxSpec::Satin { color, blend, opacity, invert } => Effect::Satin(Satin { + common: FxCommon::new(*blend, *opacity), + color: ink_color(*color), + angle: 19.0, + distance: 2.0, + size: 2.0, + contour: Contour::Linear, + anti_alias: false, + invert: *invert, + }), + FxSpec::Bevel { style, size, up, highlight, shadow } => Effect::BevelEmboss(Bevel { + enabled: true, + style: [BevelStyle::OuterBevel, BevelStyle::InnerBevel, BevelStyle::Emboss, BevelStyle::PillowEmboss, BevelStyle::StrokeEmboss] + [usize::from(*style) % 5], + technique: BevelTechnique::Smooth, + depth: 1.0, + up: *up, + size: *size, + soften: 0.0, + angle: 120.0, + altitude: 30.0, + use_global_light: false, + gloss_contour: Contour::Linear, + gloss_anti_alias: false, + highlight: FxCommon::new(BlendMode::Screen, 0.75), + highlight_color: ink_color(*highlight), + shadow: FxCommon::new(BlendMode::Multiply, 0.75), + shadow_color: ink_color(*shadow), + contour: None, + texture: None, + }), + } +} + +/// A random document with effects on some of its layers (and a knockout now and then). +fn with_effects(s: &Spec, fx: &[(usize, FxSpec)], knockout: Option) -> Document { + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + let n = d.layers.len().max(1); + for (i, f) in fx { + // Adjustment layers don't carry effects (see `split::supports`). + if let Some(l) = d.layers.get_mut(i % n).filter(|l| !matches!(l.content, LayerContent::Adjustment(_))) { + l.effects.enabled = true; + l.effects.items.push(make_effect(f)); + } + } + if let Some(l) = knockout.and_then(|i| d.layers.get_mut(i % n)) { + l.advanced.knockout = Knockout::Shallow; + } + d +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 120, ..ProptestConfig::default() })] + + /// On every stack the exact path handles, the split route gives the same inks. + #[test] + fn the_split_route_agrees_with_the_exact_path(s in spec()) { + prop_assume!(!clips_onto_pass_through(&s.layers)); + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + prop_assume!(supports(&d) && split::supports(&d)); + // Both are exact up to float rounding, which a mode that jumps can magnify (see [`jumps`]). + prop_assume!(!any_layer(&d.layers, &|l| jumps(l.blend))); + let via = split_render(&d); + let exact = render_cmyk(&d, via.rect).unwrap_or_else(|| panic!("supported")); + for (i, (g, w)) in via.px.iter().zip(&exact.px).enumerate() { + prop_assert!((g[4] - w[4]).abs() <= 1e-5, "alpha {i}: {g:?} vs {w:?}"); + if w[4] > 1e-4 { + prop_assert!((0..4).all(|c| (g[c] - w[c]).abs() <= 1e-5), "ink {i}: {g:?} vs {w:?}"); + } + } + } + + /// The RGB compositor treats every channel on its own for everything the split route + /// allows, effects included: which ink feeds which channel doesn't change any ink's result + /// (nor coverage). That is what makes running it on inks' brightness exact. + #[test] + fn effects_work_channel_by_channel( + s in spec(), + fx in prop::collection::vec((0usize..8, fx_spec()), 1..4), + knockout in prop::option::of(0usize..8), + ) { + let d = with_effects(&s, &fx, knockout); + prop_assume!(split::supports(&d)); + let rect = Rect::new(-2, -2, s.w + 2, s.h + 2); + let cmy = split::render_planes(&d, rect, [0, 1, 2]); + let rot = split::render_planes(&d, rect, [1, 2, 0]); + let k = split::render_planes(&d, rect, [3, 3, 3]); + let mix = split::render_planes(&d, rect, [3, 0, 3]); + let same = |a: f32, b: f32| (a - b).abs() <= 1e-6 || (a.is_nan() && b.is_nan()); + for i in 0..cmy.px.len() { + let (p, r, q, m) = (cmy.px[i], rot.px[i], k.px[i], mix.px[i]); + prop_assert!(same(p[3], r[3]) && same(p[3], q[3]) && same(p[3], m[3]), "coverage {i}: {p:?} {r:?} {q:?} {m:?}"); + prop_assert!(same(r[0], p[1]) && same(r[1], p[2]) && same(r[2], p[0]), "rotated {i}: {p:?} vs {r:?}"); + prop_assert!(same(q[0], q[1]) && same(q[1], q[2]), "black {i}: {q:?}"); + prop_assert!(same(m[0], q[0]) && same(m[1], p[0]) && same(m[2], q[0]), "mixed {i}: {m:?} vs {p:?} / {q:?}"); + } + } + + /// Effects don't depend on the tiling or banding (beyond the RGB compositor's own float + /// rounding, which can differ by an ulp between tilings). + #[test] + fn split_tiling_is_invisible(s in spec(), fx in prop::collection::vec((0usize..8, fx_spec()), 1..3), tile in 1i32..9) { + let d = with_effects(&s, &fx, None); + prop_assume!(split::supports(&d)); + let whole = render_cmyk_tiled(&d, d.bounds(), 1024).unwrap_or_else(|| panic!("supported")); + let tiled = render_cmyk_tiled(&d, d.bounds(), tile).unwrap_or_else(|| panic!("supported")); + for (p, q) in tiled.px.iter().zip(&whole.px) { + prop_assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() <= 1e-6), "{p:?} vs {q:?}"); + } + } +} + +/// A K-only drop shadow under K-only type on blank paper puts down black ink only. +#[test] +fn k_only_shadows_stay_k_only() { + let mut d = doc(12, 8); + d.layers.push(paper(12, 8, [0.0; 4])); + let mut t = layer(&[(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: ink_color([0.0, 0.0, 0.0, 1.0]), + angle: 135.0, + use_global_light: false, + distance: 2.0, + spread: 0.0, + size: 0.0, + contour: Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + })); + d.layers.push(t); + assert!(!supports(&d) && split::supports(&d)); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + assert!(got.px.iter().all(|p| p[..3].iter().all(|v| *v <= 1e-6)), "no C, M or Y anywhere"); + // Light from the upper left: the shadow falls a pixel down and to the right of the type. + let at = |x: i32, y: i32| got.px[(y * 12 + x) as usize]; + assert!((at(6, 5)[3] - 0.75).abs() < 1e-5, "shadow: {:?}", at(6, 5)); + assert!((at(3, 3)[3] - 1.0).abs() < 1e-6, "type: {:?}", at(3, 3)); + assert!(at(10, 1)[3] <= 1e-6, "paper: {:?}", at(10, 1)); +} + +/// A colour overlay paints the layer in exactly its inks; an outside stroke in its own. +#[test] +fn overlays_and_strokes_paint_exact_inks() { + let mut d = doc(10, 8); + d.layers.push(paper(10, 8, [0.0; 4])); + let mut l = layer(&[(Rect::new(3, 3, 7, 6), [0.0, 0.0, 0.0, 1.0, 1.0])]); + l.effects.enabled = true; + l.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: ink_color([0.2, 0.4, 0.6, 0.1]) }); + l.effects.items.push(Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 1.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(ink_color([0.0, 1.0, 0.0, 0.0])), + overprint: false, + })); + d.layers.push(l); + let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + let at = |x: i32, y: i32| got.px[(y * 10 + x) as usize]; + let near = |a: Cmyka, b: Cmyka| a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-6); + assert!(near(at(4, 4), [0.2, 0.4, 0.6, 0.1, 1.0]), "overlay: {:?}", at(4, 4)); + assert!(near(at(2, 4), [0.0, 1.0, 0.0, 0.0, 1.0]), "stroke: {:?}", at(2, 4)); + assert!(near(at(0, 0), [0.0, 0.0, 0.0, 0.0, 1.0]), "paper: {:?}", at(0, 0)); +} + +/// Pattern paints and channel-mixing effect modes stay on the RGB compositor. +#[test] +fn effects_that_mix_channels_fall_back() { + let with = |e: Effect| { + let mut d = doc(2, 2); + let mut l = layer(&[(Rect::new(0, 0, 1, 1), [0.5; 5])]); + l.effects.enabled = true; + l.effects.items.push(e); + d.layers.push(l); + d + }; + let overlay = |blend| Effect::ColorOverlay { common: FxCommon::new(blend, 1.0), color: ink_color([0.5; 4]) }; + assert!(split::supports(&with(overlay(BlendMode::Multiply)))); + assert!(!renders_inks(&with(overlay(BlendMode::Color)))); + let pattern = Effect::PatternOverlay { + common: FxCommon::new(BlendMode::Normal, 1.0), + name: "p".into(), + id: "p".into(), + scale: 1.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }; + assert!(!renders_inks(&with(pattern))); + // Effects on an adjustment layer (it has no pixels of its own to shape them). + let mut adj = adjustment(Adjustment::Invert); + adj.effects.enabled = true; + adj.effects.items.push(overlay(BlendMode::Multiply)); + let mut d = doc(2, 2); + d.layers.push(adj); + assert!(!split::supports(&d)); + // 32-bit CMYK documents too. + let mut d = with(overlay(BlendMode::Multiply)); + d.depth = SampleType::F32; + assert!(!split::supports(&d)); +} + +/// Found by a long random run: Normal mixing onto nothing must give the source's ink exactly. A +/// rounding error (1 → 0.99999994) is magnified by modes that jump at full ink, here Reflect +/// turning no ink into full K. +#[test] +fn normal_onto_nothing_is_exact() { + let shape = LayerSpec::Pixels { + kind: Kind::Shape, + blend: BlendMode::Normal, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0, 1.0])], + opacity: 1.0, + fill: 1.0, + clipped: false, + visible: true, + mask: Some(MaskSpec { rects: vec![], default: 0.4949783, density: 1.0, enabled: true }), + }; + let inv = LayerSpec::Pixels { + kind: Kind::Adjust(AdjSpec::Invert), + blend: BlendMode::Reflect, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])], + opacity: 1.0, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let mut d = doc(4, 6); + d.layers = + vec![build(&LayerSpec::Group { children: vec![shape, inv], pass_through: false, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None })]; + assert_matches_reference(&d); + assert_eq!(render1(&d, 0, 0)[..4], [0.0; 4]); +} + +/// Found by a long random run: a transparent pixel with ink of its own counts towards the +/// layer's bounds, which lay out a gradient overlay; the stand-ins keep it counted whichever ink +/// they carry. +#[test] +fn transparent_inked_pixels_keep_their_bounds() { + let inner = LayerSpec::Pixels { + kind: Kind::Raster, + blend: BlendMode::Normal, + fills: vec![(Rect::new(0, -1, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), (Rect::new(0, 1, 1, 2), [0.0, 0.0, 1.0, 0.0, 0.0])], + opacity: 1.0, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let g = |children: Vec, pass_through: bool| LayerSpec::Group { + children, + pass_through, + blend: BlendMode::Normal, + opacity: 1.0, + fill: 1.0, + mask: None, + }; + let s = Spec { w: 2, h: 2, layers: vec![g(vec![g(vec![g(vec![inner], false)], false)], true)] }; + let fx = [(0usize, FxSpec::GradientOverlay { from: [1.0, 0.0, 0.0, 0.0], to: [0.0; 4], blend: BlendMode::Normal, opacity: 1.0 })]; + let d = with_effects(&s, &fx, None); + let rect = Rect::new(-2, -2, 4, 4); + let cmy = split::render_planes(&d, rect, [0, 1, 2]); + for planes in [[3, 0, 3], [0, 0, 0], [0, 3, 3]] { + let other = split::render_planes(&d, rect, planes); + let at = planes.iter().position(|p| *p == 0).unwrap_or(0); + for (p, o) in cmy.px.iter().zip(&other.px) { + assert!((p[0] - o[at]).abs() <= 1e-6 && (p[3] - o[3]).abs() <= 1e-6, "{planes:?}: {p:?} vs {o:?}"); + } + } +} + +/// Blend modes that jump (at no ink or full ink): a rounding difference between two exact +/// computations can land on either side of the jump. +fn jumps(mode: BlendMode) -> bool { + use BlendMode as B; + matches!(mode, B::ColorBurn | B::ColorDodge | B::VividLight | B::HardMix | B::Divide | B::Reflect | B::Glow | B::PaintNetColorBurn | B::PaintNetColorDodge) +} + +fn any_layer(layers: &[Layer], f: &dyn Fn(&Layer) -> bool) -> bool { + layers.iter().any(|l| f(l) || matches!(&l.content, LayerContent::Group(g) if any_layer(&g.children, f))) +} + +/// The display form of the ink composite: where a single opaque layer's pixel shows, it is the +/// colour the RGB compositor shows (both read the inks through the document's profile). +#[test] +fn display_shows_the_inks_through_the_profile() { + let mut d = doc(8, 4); + d.layers.push(paper(8, 4, [0.0; 4])); + d.layers.push(layer(&[(Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0, 1.0]), (Rect::new(4, 0, 8, 2), [0.7, 0.1, 0.0, 0.3, 1.0])])); + let shown = render_display(&d, d.bounds()).unwrap_or_else(|| panic!("supported")); + let rgb = crate::render(&d, d.bounds()); + for (s, r) in shown.px.iter().zip(&rgb.px) { + assert!(s.iter().zip(r).all(|(a, b)| (a - b).abs() <= 1e-4), "{s:?} vs {r:?}"); + } + // Unsupported documents have no ink composite to show. + let mut lum = layer(&[(Rect::new(0, 0, 1, 1), [0.5; 5])]); + lum.blend = BlendMode::Luminosity; + d.layers.push(lum); + assert!(render_display(&d, d.bounds()).is_none()); +} + +/// The split route gives the exact path's inks on `d` (which both handle). +fn assert_routes_agree(d: &Document) { + assert!(supports(d) && split::supports(d)); + let via = split_render(d); + let exact = render_cmyk(d, via.rect).unwrap_or_else(|| panic!("supported")); + for (g, w) in via.px.iter().zip(&exact.px) { + assert!((g[4] - w[4]).abs() <= 1e-5 && (w[4] <= 1e-4 || (0..4).all(|c| (g[c] - w[c]).abs() <= 1e-5)), "{g:?} vs {w:?}"); + } +} + +/// Black-only Levels and Curves through the stand-ins: K changes, nothing else does. +#[test] +fn black_records_reach_k_through_the_stand_ins() { + use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace}; + let d0 = LevelsChannel::default; + for adj in [ + cmyk_levels(d0(), LevelsChannel { out_white: 0.5, ..d0() }), + Adjustment::Curves { + master: Vec::new(), + per_channel: [Vec::new(), Vec::new(), Vec::new()], + space: ToneSpace::Cmyk, + black: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 0.6 }], + pencil: false, + }, + ] { + let mut d = doc(3, 1); + d.layers.push(paper(3, 1, [0.1, 0.2, 0.3, 0.0])); + d.layers.push(adjustment(adj)); + assert_routes_agree(&d); + assert!(split_render(&d).px.iter().filter(|p| p[4] > 0.0).all(|p| p[3] > 0.3), "K added"); + } +} + +/// Effects are laid out over the layer's bounds, which on a layer without transparency the +/// stand-ins can't keep (its pixels that differ only in other inks would match the default): +/// such a layer stays on the RGB compositor. +#[test] +fn effects_on_opaque_layers_fall_back() { + let mut d = doc(4, 4); + let mut bg = paper(4, 4, [0.0; 4]); + bg.effects.enabled = true; + bg.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Multiply, 0.5), color: ink_color([0.0, 0.0, 0.0, 1.0]) }); + d.layers.push(bg); + assert!(!split::supports(&d) && !renders_inks(&d)); +} + +/// Solid and gradient fills without rendered pixels give the same inks on both routes (both +/// stored at the document's levels, as the compositor renders CMYK fills). +#[test] +fn fills_agree_on_both_routes() { + let g = photocraft_doc::Fill::gradient( + vec![(0.0, cmyk_color([1.0, 0.2, 0.0, 0.0], 1.0)), (1.0, cmyk_color([0.0, 0.0, 0.7, 1.0], 0.6))], + 30.0, + 1.0, + Default::default(), + false, + ); + for fill in [g, photocraft_doc::Fill::Solid(cmyk_color([0.13, 0.0, 0.9375, 0.0], 0.55))] { + let mut d = doc(9, 5); + d.layers.push(paper(9, 5, [0.0, 0.1, 0.0, 0.0])); + d.layers.push(Layer::new("f", LayerContent::Fill(fill))); + assert_routes_agree(&d); + } +} + +/// Flatten Image, Stamp Visible and Copy Merged (all [`crate::flatten_to_surface`]) keep a CMYK +/// document's inks: K-only black stays K-only instead of turning four-colour. +#[test] +fn flattening_keeps_exact_inks() { + let mut d = doc(3, 1); + d.layers.push(layer(&[ + (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0, 1.0]), + (Rect::new(1, 0, 2, 1), [0.2, 0.4, 0.6, 0.1, 1.0]), + (Rect::new(2, 0, 3, 1), [0.0, 0.0, 0.0, 1.0, 0.4]), + ])); + // Over white paper (Flatten Image) and kept transparent (Stamp Visible). + let flat = crate::flatten_to_surface(&d, CMYK8, Some([1.0; 3])); + assert_eq!(flat.pixel(0, 0), vec![0.0, 0.0, 0.0, 1.0]); + assert_eq!(flat.pixel(1, 0), vec![q(0.2), q(0.4), q(0.6), q(0.1)]); + assert_eq!(flat.pixel(2, 0), vec![0.0, 0.0, 0.0, q(0.4)], "40% K over paper"); + let stamp = crate::flatten_to_surface(&d, CMYKA8, None); + assert_eq!(stamp.pixel(2, 0), vec![0.0, 0.0, 0.0, 1.0, q(0.4)]); +} + +/// A colour given in RGB becomes ink through the document's own CMYK profile, not the built-in +/// coated one, on both ink paths: a solid fill on the exact path (its tiles render on worker +/// threads) and a Color Overlay on the split path (its stand-ins). +#[test] +fn rgb_colours_become_ink_through_the_document_profile() { + let profile = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams { + description: "Test Uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }); + let bytes = profile.to_bytes(); + let space = photocraft_color::convert::CmykSpace::for_profile(Some(&bytes)); + assert!(space.is_some(), "a profile other than the built-in one"); + let rgb = photocraft_color::Color { mode: ColorMode::Rgb, c: [0.2, 0.5, 0.8, 0.0], alpha: 1.0 }; + let want = photocraft_color::convert::with_cmyk_space(space.as_ref(), || rgb.in_mode(ColorMode::Cmyk).c); + let builtin = rgb.in_mode(ColorMode::Cmyk).c; + assert!((0..4).any(|i| (want[i] - builtin[i]).abs() > 0.02), "the profiles differ: {want:?} {builtin:?}"); + let check = |d: &Document, what: &str| { + let got = render_cmyk(d, d.bounds()).unwrap_or_else(|| panic!("supported")); + for p in &got.px { + assert!((0..4).all(|i| (p[i] - want[i]).abs() <= 1.0 / 255.0) && p[4] == 1.0, "{what}: {p:?}, want {want:?} (built-in {builtin:?})"); + } + }; + + let mut exact = doc(8, 4); + exact.icc_profile = Some(bytes.clone()); + exact.layers.push(Layer::new("fill", LayerContent::Fill(photocraft_doc::Fill::Solid(rgb)))); + assert!(supports(&exact)); + check(&exact, "solid fill"); + + let mut overlay = doc(8, 4); + overlay.icc_profile = Some(bytes); + let mut l = layer(&[(Rect::new(0, 0, 8, 4), [0.0, 0.0, 0.0, 1.0, 1.0])]); + l.effects.enabled = true; + l.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: rgb }); + overlay.layers.push(l); + assert!(!supports(&overlay) && split::supports(&overlay)); + check(&overlay, "colour overlay"); +} + +/// The split route and the exact path give the same inks for `s`, alpha included. +fn assert_split_matches_exact(s: &Spec) { + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + assert!(supports(&d) && split::supports(&d)); + let via = split_render(&d); + let exact = render_cmyk(&d, via.rect).unwrap_or_else(|| panic!("supported")); + for (i, (g, w)) in via.px.iter().zip(&exact.px).enumerate() { + assert!((0..5).all(|c| (g[c] - w[c]).abs() <= 1e-5), "pixel {i}: {g:?} vs {w:?}"); + } +} + +fn raster_spec(blend: BlendMode, fill: (Rect, Cmyka), opacity: f32, clipped: bool) -> LayerSpec { + LayerSpec::Pixels { kind: Kind::Raster, blend, fills: vec![fill], opacity, fill: 1.0, clipped, visible: true, mask: None } +} + +fn group_spec(children: Vec, pass_through: bool) -> LayerSpec { + LayerSpec::Group { children, pass_through, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None } +} + +/// A clipped Dissolve layer shows each pixel fully or not at all on the exact path too, as on the +/// RGB compositor (#9033; it used to mix like Normal: K 0.81 where the noise hides the layer). +#[test] +fn a_clipped_dissolve_layer_dissolves_on_the_exact_path() { + let s = Spec { + w: 4, + h: 5, + layers: vec![group_spec( + vec![ + raster_spec(BlendMode::Normal, (Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0, 1.0]), 1.0, false), + raster_spec(BlendMode::Dissolve, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 0.189_737_02, true), + ], + false, + )], + }; + assert_split_matches_exact(&s); +} + +/// A Dissolve adjustment layer takes each pixel fully or not at all on the exact path too (#9033; +/// it used to mix by opacity: cyan 0.267 where the noise picks the adjusted 0.298). +#[test] +fn a_dissolve_adjustment_dissolves_on_the_exact_path() { + let levels = AdjSpec::Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }; + let adjust = LayerSpec::Pixels { + kind: Kind::Adjust(levels), + blend: BlendMode::Dissolve, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])], + opacity: 0.897_210_9, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let s = Spec { + w: 3, + h: 2, + layers: vec![ + group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false)], false), + group_spec(vec![adjust], true), + ], + }; + assert_split_matches_exact(&s); +} + +/// After an adjustment layer, the display route ([`crate::native`]) rounds to the document's +/// levels like the export path and the RGB compositor, at any opacity (#9033: in a pass-through +/// group its coverage stayed at 41.32% where the others store 105/255). +#[test] +fn every_cmyk_route_rounds_after_an_adjustment_layer() { + for opacity in [0.0, 0.5] { + let adjust = LayerSpec::Pixels { + kind: Kind::Adjust(AdjSpec::Invert), + blend: BlendMode::Normal, + fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])], + opacity, + fill: 1.0, + clipped: false, + visible: true, + mask: None, + }; + let s = Spec { + w: 8, + h: 5, + layers: vec![group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false), adjust], true)], + }; + let mut d = doc(s.w as u32, s.h as u32); + d.layers = s.layers.iter().map(build).collect(); + if let LayerContent::Group(g) = &mut d.layers[0].content { + g.children[0].fill_opacity = 0.413_248_66; + } + let rect = Rect::new(0, 0, 1, 1); + let display = crate::render(&d, rect).px[0][3]; + let export = render_cmyk(&d, rect).unwrap_or_else(|| panic!("supported")).px[0][4]; + assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}"); + } +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 264dc3529b..cc24e479c3 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -2234,23 +2234,8 @@ pub(crate) fn composite_with_effects_prepared( let mut share = vec![0.0f32; w * h]; for (i, sh) in share.iter_mut().enumerate() { let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0); - let k = if maps.outline { - band * outline_share(shape.v[i], lay.px[i][3]) - } else if inside(shape.v[i]) { - if vector_shape { - 0.0 - } else { - // The stroke lies outside the layer's pixels. Beneath the layer it may show - // only through the part of the pixel the shape doesn't cover (1 - a); the - // layer composited on top covers c = a × fill, so the share beneath is - // (1 - a) / (1 - c). At 100 % fill that is 1 (the layer hides the rest, as - // before); at 0 % fill the interior stays clear (Fill 0 % + Outside stroke). - let c = lay.px[i][3].clamp(0.0, 1.0); - if c >= 1.0 { 1.0 } else { ((1.0 - shape.v[i].clamp(0.0, 1.0)) / (1.0 - c)).clamp(0.0, 1.0) } - } - } else { - band - }; + let k = + if inside(shape.v[i]) { if vector_shape && !maps.outline { 0.0 } else { band * outline_share(shape.v[i], lay.px[i][3]) } } else { band }; let mut k = k * st.common.opacity; if st.common.blend == BlendMode::Dissolve { let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64); @@ -2339,6 +2324,9 @@ pub(crate) fn composite_with_effects_prepared( /// layer, `share + l (1 - share) = (1 - cov) + l`. pub fn outline_share(cov: f32, l: f32) -> f32 { let outside = (1.0 - cov.clamp(0.0, 1.0)).max(0.0); + if outside <= 1e-6 { + return 0.0; + } let rest = 1.0 - l.clamp(0.0, 1.0); if rest <= 1e-6 { 1.0 } else { (outside / rest).min(1.0) } } diff --git a/crates/compose/src/filter_mask.rs b/crates/compose/src/filter_mask.rs index b845720456..2d9031f5d4 100644 --- a/crates/compose/src/filter_mask.rs +++ b/crates/compose/src/filter_mask.rs @@ -21,27 +21,38 @@ pub fn mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface { return out; } let n = fmt.channels(); - let (b, f) = (base.read_region(area), filtered.read_region(area)); - let mut k = Vec::new(); - mask.values_into(area, &mut k); - let mut o = vec![0.0f32; b.len()]; - for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { - let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); - if !fmt.alpha { - for c in 0..n { - op[c] = bp[c] + (fp[c] - bp[c]) * m; - } + // Tile by tile: the union of two sparse surfaces far apart is a rectangle of mostly nothing, + // and transparent pixels in tiles neither surface has stay as they are. + let whole = area; + let same_default = base.default_pixel() == filtered.default_pixel(); + for t in whole.tiles() { + let area = t.rect().intersect(&whole); + let bare = !base.has_tiles_in(area) && !filtered.has_tiles_in(area); + if area.is_empty() || (bare && (fmt.alpha || same_default)) { continue; } - let a = n - 1; - let oa = bp[a] + (fp[a] - bp[a]) * m; - op[a] = oa; - for c in 0..a { - let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m; - op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 }; + let (b, f) = (base.read_region(area), filtered.read_region(area)); + let mut k = Vec::new(); + mask.values_into(area, &mut k); + let mut o = vec![0.0f32; b.len()]; + for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { + let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); + if !fmt.alpha { + for c in 0..n { + op[c] = bp[c] + (fp[c] - bp[c]) * m; + } + continue; + } + let a = n - 1; + let oa = bp[a] + (fp[a] - bp[a]) * m; + op[a] = oa; + for c in 0..a { + let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m; + op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 }; + } } + out.write_region(area, &o); } - out.write_region(area, &o); out.prune(); out } @@ -61,38 +72,51 @@ pub fn unmix(base: &Surface, mixed: &Surface, mask: &LayerMask) -> Surface { return out; } let n = fmt.channels(); - let (b, x) = (base.read_region(area), mixed.read_region(area)); - let mut k = Vec::new(); - mask.values_into(area, &mut k); - let mut o = vec![0.0f32; b.len()]; - for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { - let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); - if m < HIDDEN { - // A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the - // stored pixels don't grow past the content. - if !fmt.alpha || bp[n - 1] > 0.0 { - op.copy_from_slice(bp); + // Tile by tile: the union of two sparse surfaces far apart is a rectangle of mostly nothing, + // and transparent pixels in tiles neither surface has stay as they are. + let whole = area; + // Non-alpha still needs every tile whose defaults differ (an unmix through a non-unit mask + // would change that default, and the clone already holds the old one). + let same_default = base.default_pixel() == mixed.default_pixel(); + for t in whole.tiles() { + let area = t.rect().intersect(&whole); + let bare = !base.has_tiles_in(area) && !mixed.has_tiles_in(area); + if area.is_empty() || (bare && (fmt.alpha || same_default)) { + continue; + } + let (b, x) = (base.read_region(area), mixed.read_region(area)); + let mut k = Vec::new(); + mask.values_into(area, &mut k); + let mut o = vec![0.0f32; b.len()]; + for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { + let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); + if m < HIDDEN { + // A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the + // stored pixels don't grow past the content. + if !fmt.alpha || bp[n - 1] > 0.0 { + op.copy_from_slice(bp); + } + continue; } - continue; - } - if !fmt.alpha { - for c in 0..n { - op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0); + if !fmt.alpha { + for c in 0..n { + op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0); + } + continue; + } + let a = n - 1; + let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0); + if fa <= 0.0 { + continue; + } + op[a] = fa; + for c in 0..a { + let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m; + op[c] = (pm / fa).clamp(0.0, 1.0); } - continue; - } - let a = n - 1; - let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0); - if fa <= 0.0 { - continue; - } - op[a] = fa; - for c in 0..a { - let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m; - op[c] = (pm / fa).clamp(0.0, 1.0); } + out.write_region(area, &o); } - out.write_region(area, &o); out.prune(); out } @@ -122,6 +146,19 @@ mod tests { m } + #[test] + fn mixing_sparse_surfaces_far_apart_does_not_build_their_union() { + // 10¹⁰ px between two single pixels would be 160 GB of floats read densely. + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let (mut base, mut filtered) = (Surface::new(fmt), Surface::new(fmt)); + base.write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + filtered.write_pixel(100_000, 100_000, &[1.0, 0.0, 0.0, 1.0]); + let mixed = mix(&base, &filtered, &LayerMask::reveal_all()); + assert_eq!(mixed.pixel(100_000, 100_000), vec![1.0, 0.0, 0.0, 1.0]); + let back = unmix(&base, &mixed, &LayerMask::reveal_all()); + assert_eq!(back.pixel(100_000, 100_000), vec![1.0, 0.0, 0.0, 1.0]); + } + #[test] fn unmix_inverts_mix_wherever_the_mask_shows_the_filter() { for fmt in [PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), PixelFormat::new(ColorMode::Rgb, SampleType::F32, false)] { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e74cfd6d2f..4786bc1912 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -19,6 +19,7 @@ pub mod adjust; pub mod advanced; pub mod bounds; +pub mod cmyk; pub mod effects; pub mod fill_layout; pub mod filter_mask; @@ -150,6 +151,22 @@ fn scratch_reuse_preserves_pixels_and_bounds_retention() { }); } +/// Every layer the compositor draws, groups before their children: hidden layers and everything +/// inside hidden groups are left out. +pub(crate) fn visible_layers(layers: &[Layer]) -> Vec<&Layer> { + fn rec<'a>(layers: &'a [Layer], out: &mut Vec<&'a Layer>) { + for l in layers.iter().filter(|l| l.visible) { + out.push(l); + if let Some(children) = l.children() { + rec(children, out); + } + } + } + let mut out = Vec::new(); + rec(layers, &mut out); + out +} + /// Composite the whole document over `rect`, in parallel 256² tiles on /// native targets (single-threaded on wasm). pub fn render(doc: &Document, rect: Rect) -> Buffer { @@ -327,9 +344,12 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 { /// leaves colour behind in CMYK (K = 100%) and Lab (neutral a/b = 0.5), and those pixels would /// otherwise count as content: the result would span the whole canvas. /// -/// A CMYK document of plain Normal layers is composited in its ink values instead -/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778), -/// and a Lab one in its L, a and b ([`native_lab::flatten_lab`]). +/// A CMYK document flattened to CMYK is composited in its ink values instead, so flattening +/// doesn't round-trip its pixels through RGB: plain Normal layers by [`native::flatten_cmyk`] +/// (#3778), and masks, groups, clipping, other blend modes and the other layer kinds by [`cmyk`] +/// where its layers mix on their inks (#2220). Otherwise the composite goes back to CMYK through +/// the document's own profile, the one its layers were read through. A Lab document of plain +/// layers is composited in its L, a and b ([`native_lab::flatten_lab`]). pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface { composite_to_surface(doc, doc.bounds(), fmt, background) } @@ -351,9 +371,39 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P let n = fmt.channels(); let empty = s.default_pixel(); let mut data = Vec::new(); + let space = cmyk_space(doc); + let to_cmyk = fmt.mode == photocraft_color::ColorMode::Cmyk && doc.mode == photocraft_color::ColorMode::Cmyk; + if to_cmyk && cmyk::renders_inks(doc) { + // White paper is no ink; another background colour goes through the profile. + let paper = background.map(|bg| { + if bg == [1.0; 3] { [0.0; 4] } else { photocraft_color::convert::with_cmyk_space(space.as_ref(), || photocraft_color::convert::rgb_to_cmyk(bg)) } + }); + let _ = cmyk::render_cmyk_bands(doc, rect, 0, |band| -> Result<(), ()> { + data.clear(); + data.reserve(band.px.len() * n); + for p in &band.px { + if paper.is_none() && fmt.alpha && (p[4] <= 0.0 || p[4].is_nan()) && empty.len() == n { + data.extend_from_slice(&empty); + continue; + } + // Over the paper (fully covered pixels keep their inks exactly), or straight. + let ink = |c: usize| match paper { + Some(bg) if p[4] < 1.0 => p[c] * p[4] + bg[c] * (1.0 - p[4]), + _ => p[c], + }; + data.extend((0..4).map(ink)); + if fmt.alpha { + data.push(if paper.is_some() { 1.0 } else { p[4] }); + } + } + s.write_region(band.rect, &data); + Ok(()) + }); + s.prune(); + return s; + } // The composite separates back to CMYK through the document's own profile, the one its // pixels were read through. - let cmyk = cmyk_space(doc); let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> { let band = match background { Some(bg) => band.over_background(bg), @@ -361,7 +411,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P }; data.clear(); data.resize(band.px.len() * n, 0.0); - photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) { if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n { out.copy_from_slice(&empty); @@ -383,7 +433,11 @@ pub fn flatten(doc: &Document) -> Buffer { } /// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated. +/// Rects past [`MAX_RENDER_PIXELS`] give an empty buffer. pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } let mut buf = Buffer::transparent(rect); let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); composite_stack_at( @@ -870,7 +924,8 @@ impl<'a> Ctx<'a> { if identity == [true; 3] { return; } - adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity); + let extrapolate = adjust::float_tone_extrapolates(adj, Some(self.depth), self.mode); + adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity, extrapolate); } else { adjust::apply_in(adj, buf, self.transfer, Some(self.depth), self.mode); } @@ -1720,14 +1775,34 @@ fn blend_if_level(x: f32) -> f32 { if (v - r).abs() < 1e-4 { r } else { v } } +/// Blend If groups inside one another whose own colour is still rendered separately. Each extra +/// level multiplies the work by the tree's depth, so the cost is polynomial, not exponential. +const MAX_BLEND_IF_GROUP_DEPTH: u32 = 2; + +thread_local! { + /// Blend If groups being rendered for their own colour on this thread. + static BLEND_IF_GROUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn) /// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer. /// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since /// every blend mode's source-over result is linear in the source alpha. fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) { // "This Layer" is the layer's own colour; adjustment layers (no content of their own) are - // judged by their result. - let own = if matches!(layer.content, LayerContent::Adjustment(_)) { None } else { render_content(layer, out.rect, cx) }; + // judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend + // Ifs: a group's own colour re-composites its children, which doubles the work at every + // level of Blend If groups inside one another. + let is_group = matches!(layer.content, LayerContent::Group(_)); + let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get); + let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) { + None + } else { + BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group))); + let own = render_content(layer, out.rect, cx); + BLEND_IF_GROUPS.with(|d| d.set(nested)); + own + }; for (i, (p, b)) in out.px.iter_mut().zip(&before.px).enumerate() { let this = match &own { Some(o) => Some(o.px[i]).filter(|q| q[3] > 0.0), @@ -2226,12 +2301,21 @@ fn fx_region_bytes(layers: &[Layer], canvas: Rect) -> u64 { bytes(layers, canvas, 0) } +/// Most bytes one layer's effect maps and source may take to build, whatever the cache budget. +const MAX_FX_BUILD_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 512 << 20 }; + /// The layer's effect maps over its region (layer bounds grown by the effect reach, within the /// canvas grown likewise; see [`effect_region`]), built once per layer state and shared by every /// tile. `rect` is what the caller composites the layer over (see [`effect_region`]). fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc { use std::hash::{Hash, Hasher}; - let (region, clipped) = effect_region(layer, rect, cx); + let (mut region, clipped) = effect_region(layer, rect, cx); + // Non-local effects keep their whole source region, which a few distant pixels stretch over + // the canvas: past the cap the effects are left out instead of allocating it. + let region_px = u64::from(region.width()) * u64::from(region.height()); + if (effects::map_count(layer) as u64 + 1).saturating_mul(region_px).saturating_mul(4) > MAX_FX_BUILD_BYTES { + region = Rect::EMPTY; + } static NO_CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); if *NO_CACHE.get_or_init(|| std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some()) { let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; diff --git a/crates/compose/src/native.rs b/crates/compose/src/native.rs index 31e17f3ea2..98f87f75a9 100644 --- a/crates/compose/src/native.rs +++ b/crates/compose/src/native.rs @@ -189,7 +189,10 @@ impl<'a> InkStack<'a> { match item { Item::Layer(surf, opacity, mask) => { let opacity = *opacity; - if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) { + // Nothing to draw only where the surface is transparent: without an alpha + // channel its default pixel is opaque (no ink is blank paper, not nothing). + let clear = surf.format().alpha && surf.default_pixel().last().is_some_and(|a| *a <= 0.0); + if opacity <= 0.0 || (clear && surf.content_bounds().intersect(&rect).is_empty()) { continue; } let sf = surf.format(); @@ -211,7 +214,13 @@ impl<'a> InkStack<'a> { } } Item::Adjust(op, opacity, mask) => { + // The result is rounded to the document's levels, as the compositor and the + // export path do after every adjustment layer, at any opacity (#9033). + let quantum = crate::adjustment_quantum(self.depth); if *opacity <= 0.0 { + if let Some(q) = quantum { + crate::cmyk::quantize_inks(acc, q); + } continue; } masked(*mask, mv); @@ -225,6 +234,9 @@ impl<'a> InkStack<'a> { d[c] += (v - d[c]) * mix; } } + if let Some(q) = quantum { + crate::cmyk::quantize_inks(acc, q); + } } } } diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index f48f10d83f..dba123e21c 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -112,7 +112,8 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document { /// with it. Empty vector masks have constant coverage, including inversion and density. /// Nonempty paths can still anchor fill layouts even when their mask density is zero. pub fn proxy_faithful(doc: &Document) -> bool { - doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty())) + // Hidden layers (and what they hold) are not composited, so they don't count. + crate::visible_layers(&doc.layers).iter().all(|l| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty())) } #[cfg(test)] @@ -263,6 +264,25 @@ mod tests { assert!(proxy_faithful(&doc)); } + #[test] + fn a_hidden_layer_with_effects_does_not_rule_out_the_proxy_or_the_uniform_shortcut() { + let mut doc = two_colors(Size::new(32, 16), SampleType::U8); + doc.layers[1].effects.items.push(Effect::default_drop_shadow()); + doc.layers[1].effects.enabled = true; + assert!(!proxy_faithful(&doc)); + assert!(crate::uniform::UniformAreas::new(&doc).is_none()); + doc.layers[1].visible = false; + assert!(proxy_faithful(&doc)); + assert!(crate::uniform::UniformAreas::new(&doc).is_some()); + // Inside a hidden group too. + doc.layers[1].visible = true; + let group = Layer::group("g", vec![doc.layers.remove(1)]); + doc.layers.push(group); + assert!(!proxy_faithful(&doc)); + doc.layers[1].visible = false; + assert!(proxy_faithful(&doc)); + } + #[test] fn empty_vector_masks_keep_constant_coverage_at_every_depth() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 09d404669b..b2fd28116e 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -8,7 +8,7 @@ use std::sync::{Mutex, OnceLock}; use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::vector::ShapeLayer; -use photocraft_geom::Rect; +use photocraft_geom::{Rect, TileCoord}; use photocraft_raster::Surface; /// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so @@ -118,13 +118,42 @@ fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rec if r.is_empty() { return; } + let float = stroke.format().sample == SampleType::F32; + // Tiles none of the three reaches read as the defaults; with transparent defaults the fit + // leaves the stroke's default pixel there, so those tiles are exact skips and we can scan + // just the union of the three surfaces' allocated tiles instead of every canvas tile (two + // sparse regions on a 300k×300k document would otherwise walk ~1.4M empty tiles). A + // non-transparent default is everywhere, so the scan falls back to the full rectangle. + let whole = r; + let bare_transparent = [fill, &*stroke, cache].iter().all(|s| s.default_pixel().iter().all(|v| *v == 0.0)); + if bare_transparent { + let mut coords: Vec = fill.tiles().map(|(c, _)| *c).chain(stroke.tiles().map(|(c, _)| *c)).chain(cache.tiles().map(|(c, _)| *c)).collect(); + coords.sort_unstable(); + coords.dedup(); + for c in coords { + let r = c.rect().intersect(&whole); + if !r.is_empty() { + fit_stroke_rect(fill, stroke, cache, r, float); + } + } + } else { + for t in whole.tiles() { + let r = t.rect().intersect(&whole); + if !r.is_empty() { + fit_stroke_rect(fill, stroke, cache, r, float); + } + } + } +} + +/// [`fit_stroke`] over one rectangle. +fn fit_stroke_rect(fill: &Surface, stroke: &mut Surface, cache: &Surface, r: Rect, float: bool) { let n = r.width() as usize * r.height() as usize; let (mut f, mut s, mut c) = (vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n]); fill.read_rgba_into(r, &mut f); stroke.read_rgba_into(r, &mut s); cache.read_rgba_into(r, &mut c); let mut out = Vec::with_capacity(n * 4); - let float = stroke.format().sample == SampleType::F32; for ((fp, sp), cp) in f.iter().zip(&s).zip(&c) { let mut p = *sp; if fp[3] < 1.0 - 1e-3 { diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs index 416c062442..3608327628 100644 --- a/crates/compose/src/stroke_tests.rs +++ b/crates/compose/src/stroke_tests.rs @@ -92,6 +92,7 @@ fn outline_share_treats_stroke_and_shape_as_disjoint_areas() { // Outside the shape the stroke covers the pixel; inside it nothing. assert_eq!(effects::outline_share(0.0, 0.0), 1.0); assert_eq!(effects::outline_share(1.0, 0.3), 0.0); + assert_eq!(effects::outline_share(1.0, 1.0), 0.0); // Beneath an opaque edge pixel the result ends opaque: share + l (1 - share) = 1 - cov + l. let (cov, l) = (0.6, 0.6); let s = effects::outline_share(cov, l); @@ -256,3 +257,69 @@ fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() { assert!(close4(px(&d, 20, 20), [fill, fill, fill, 1.0]), "{sample:?} {:?}", px(&d, 20, 20)); } } + +#[test] +fn outside_stroke_on_raster_layer_does_not_paint_inside_opaque_pixels() { + let mut d = doc(40, 40); + let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8); + pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]); + let mut l = Layer::new("raster", LayerContent::Raster(pixels)); + l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())]; + d.layers.push(l); + let center = px(&d, 20, 20); + assert!(close4(center, [1.0, 0.0, 0.0, 1.0]), "interior must stay red, got: {center:?}"); + let stroke_pixel = px(&d, 13, 20); + assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue, got: {stroke_pixel:?}"); +} + +/// Beneath an opaque pixel the Outside stroke has no share, so a Multiply layer blended as a +/// group with its interior effects (where the layer's mode meets what lies beneath it inside the +/// style) multiplies the backdrop there, not the stroke colour (red × blue would be black), for a +/// raster layer and a filled shape alike. +#[test] +fn outside_stroke_under_multiply_layer_leaves_interior_to_backdrop() { + let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8); + pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]); + let layers = [("raster", Layer::new("raster", LayerContent::Raster(pixels))), ("shape", shape(15.0, 25.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), None))]; + for (what, mut l) in layers { + let mut d = doc(40, 40); + l.blend = BlendMode::Multiply; + l.advanced.blend_interior = true; + l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())]; + d.layers.push(l); + let center = px(&d, 20, 20); + assert!(close4(center, [1.0, 0.0, 0.0, 1.0]), "{what}: interior must be red × white, got: {center:?}"); + let stroke_pixel = px(&d, 13, 20); + assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "{what}: outside stroke must be blue, got: {stroke_pixel:?}"); + } +} + +#[test] +fn outside_stroke_on_zero_fill_raster_layer_keeps_interior_clear() { + let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8); + let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8); + pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]); + let mut l = Layer::new("raster", LayerContent::Raster(pixels)); + l.fill_opacity = 0.0; + l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())]; + d.layers.push(l); + let center = px(&d, 20, 20); + assert_eq!(center[3], 0.0, "interior with 0% fill must stay clear: {center:?}"); + let stroke_pixel = px(&d, 13, 20); + assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue: {stroke_pixel:?}"); +} + +#[test] +fn outside_stroke_on_partial_fill_raster_layer_keeps_interior_free_of_stroke() { + let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8); + let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8); + pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]); + let mut l = Layer::new("raster", LayerContent::Raster(pixels)); + l.fill_opacity = 0.5; + l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())]; + d.layers.push(l); + let center = px(&d, 20, 20); + assert!(close4(center, [1.0, 0.0, 0.0, 0.5]), "interior must be 50% red: {center:?}"); + let stroke_pixel = px(&d, 13, 20); + assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue: {stroke_pixel:?}"); +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 8f111269a8..76ba8dc72b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -94,7 +94,7 @@ fn identity_float_curves_match_previous_mapping_bits() { px: vec![[2.0, -0.0, -0.2, 1.0], [f32::from_bits(0x7fc01234), f32::INFINITY, f32::NEG_INFINITY, 0.6], [0.7, -0.0, 1.5, 0.0], [0.1; 4]], }; let mut previous = input.clone(); - adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3]); + adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3], false); let mut actual = input.clone(); adjust::apply_depth(&adj, &mut actual, adjust::Transfer::Gamma(1.0), Some(SampleType::F32)); assert_buffer_bits(&actual, &previous); @@ -1099,6 +1099,19 @@ fn cmyk_ink_compositing_inlines_open_groups() { assert!(close4(px(&d, 3, 1), [c[0], c[1], c[2], 1.0]), "outside the group's layer"); } +/// A CMYK layer without an alpha channel is opaque everywhere, its untouched pixels blank paper +/// (no ink): the ink compositor keeps it under an adjustment rather than skipping it as empty. +#[test] +fn cmyk_ink_compositing_keeps_blank_paper_without_alpha() { + let mut d = Document::new("paper", Size::new(4, 4), ColorMode::Cmyk, SampleType::U8); + d.layers.push(Layer::raster("paper", PixelFormat::new(ColorMode::Cmyk, SampleType::U8, false))); + d.layers.push(Layer::new("t", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 }))); + assert!(native::render_cmyk(&d, d.bounds(), 64).is_some()); + // Paper is white, so Threshold keeps it paper. + let white = photocraft_color::convert::cmyk_to_rgb([0.0; 4]); + assert!(close4(px(&d, 2, 2), [white[0], white[1], white[2], 1.0]), "{:?}", px(&d, 2, 2)); +} + #[test] fn cmyk_document_renders_via_rgb() { let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8); @@ -1499,7 +1512,12 @@ fn glow_gradient_follows_the_shape() { // new pixels (#4053). Effects that don't depend on the frame repaint only what changed. #[test] fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() { - let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }; + let overlay = Effect::GradientOverlay { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + gradient: Gradient::default(), + dither: false, + interpolation: None, + }; let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| { let mut b = a.clone(); let old = b.layers.last().unwrap().clone(); @@ -1526,13 +1544,19 @@ fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() { #[test] fn gradient_overlay_follows_angle_and_reverse() { let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() }; - let d = fx_doc(vec![Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: g.clone(), dither: false }]); + let d = fx_doc(vec![Effect::GradientOverlay { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + gradient: g.clone(), + dither: false, + interpolation: None, + }]); let (l, r) = (px(&d, 10, 20), px(&d, 29, 20)); assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}"); let d = fx_doc(vec![Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient { reverse: true, style: GradientStyle::Linear, ..g }, dither: false, + interpolation: None, }]); assert!(px(&d, 10, 20)[0] > 0.9); } @@ -1737,6 +1761,20 @@ fn vector_mask_combines_with_pixel_mask() { assert!(close4(px(&d, 6, 1), [1.0; 4])); } +#[test] +fn effect_maps_over_distant_pixels_are_not_built_over_the_canvas() { + // Two pixels 190000 apart: the effect source region spans 3.6·10¹⁰ px. + let mut doc = Document::new("big", Size::new(200_000, 200_000), ColorMode::Rgb, SampleType::U8); + let mut l = solid_layer("fx", Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(190_000, 190_000, 190_001, 190_001), &[1.0, 0.0, 0.0, 1.0]); + l.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + doc.layers.push(l); + let t = std::time::Instant::now(); + let px = render(&doc, Rect::new(0, 0, 4, 4)); + assert_eq!(px.px.len(), 16); + assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed()); +} + #[test] fn effect_maps_are_cached_and_invalidated_by_pixel_changes() { let mut doc = doc_white(64, 64); @@ -2326,6 +2364,26 @@ fn blend_if_this_layer_hides_by_the_layers_own_value() { assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0])); } +#[test] +fn nested_blend_if_groups_do_not_double_the_work_at_every_level() { + // Each Blend If group re-renders its children for its own colour; 40 levels would be 2^40. + let mut inner = solid_layer("dot", Rect::new(0, 0, 4, 4), [1.0, 1.0, 1.0, 1.0]); + inner.blend_if.set(0, [range([10, 10], [255, 255]), FULL]); + let mut layers = vec![inner]; + for _ in 0..40 { + let mut g = Layer::group("g", layers); + g.blend_if.set(0, [range([10, 10], [255, 255]), FULL]); + layers = vec![g]; + } + // A black backdrop, not doc_white's white: the asserted white can then only come from the + // inner layer rendered through the whole group chain, not from the backdrop itself. + let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::BLACK); + d.layers.extend(layers); + let t = std::time::Instant::now(); + assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0])); + assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed()); +} + #[test] fn blend_if_underlying_layer_hides_by_the_backdrop_value() { // Backdrop black on the left, white on the right; a red layer over all of it. @@ -2700,7 +2758,7 @@ fn levels_steps_match_photoshop() { // jump where it meets the power curve. #[test] fn levels_gamma_toe_is_monotone_for_any_gamma() { - for g in [1.0001, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] { + for g in [1.0001, 1.01, 1.0105422, 1.02, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] { let mut last = 0.0f32; for k in 0..=4096 { let u = k as f32 / 4096.0; @@ -2764,6 +2822,53 @@ fn exposure_scales_32_bit_samples_as_stored() { } } +/// #7157, measured on Photoshop 27.11: an edited Curves in a 32-bit RGB document continues past +/// 0..1 along its end segments instead of clipping. The straight curve `[[0,0],[255,128]]` maps +/// -0.5, 0.5 and 2 to -0.250980407, 0.250980407 and 1.003921628, through Image › Adjustments and +/// through an adjustment layer alike. 8/16-bit documents still clip to the table. +#[test] +fn float_curves_extend_past_the_unit_range_like_photoshop() { + use photocraft_doc::adjust::CurvePoint; + let line = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 128.0 / 255.0 }]; + let ident = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]; + let adj = Adjustment::Curves { master: line, per_channel: std::array::from_fn(|_| ident.clone()), space: ToneSpace::Rgb, black: Vec::new(), pencil: false }; + let input = Buffer { rect: Rect::new(0, 0, 3, 1), px: vec![[-0.5, -0.5, -0.5, 1.0], [0.5, 0.5, 0.5, 1.0], [2.0, 2.0, 2.0, 1.0]] }; + // Photoshop's samples, compared in f64 so the reference keeps its printed digits. + let want = [-0.250980407f64, 0.250980407, 1.003921628]; + let check = |got: &Buffer, what: &str| { + for (p, w) in got.px.iter().zip(want) { + assert!(p[..3].iter().all(|v| (f64::from(*v) - w).abs() < 2e-5), "{what}: {p:?} vs {w}"); + assert_eq!(p[3], 1.0); + } + }; + let mut destructive = input.clone(); + adjust::apply_depth(&adj, &mut destructive, adjust::Transfer::Gamma(1.0), Some(SampleType::F32)); + check(&destructive, "Image › Adjustments"); + let doc = Document::new("hdr", Size::new(3, 1), ColorMode::Rgb, SampleType::F32); + let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); + let cx = Ctx::for_doc(&doc, &patterns); + let mut layer = input.clone(); + cx.apply_adjustment(&adj, &mut layer); + check(&layer, "adjustment layer"); + // Not at 8 or 16 bits: those samples never leave 0..1, and the table's ends hold. + for depth in [SampleType::U8, SampleType::U16] { + let mut clipped = input.clone(); + adjust::apply_depth(&adj, &mut clipped, adjust::Transfer::for_document(ColorMode::Rgb, depth), Some(depth)); + assert!(clipped.px[2][..3].iter().all(|v| *v <= 128.0 / 255.0 + 1e-4), "{depth:?}: {:?}", clipped.px[2]); + } + // Only in RGB documents: a 32-bit grayscale document keeps the table's end. + let mut gray = input.clone(); + adjust::apply_in(&adj, &mut gray, adjust::Transfer::Gamma(1.0), Some(SampleType::F32), ColorMode::Grayscale); + assert!(gray.px[2][..3].iter().all(|v| *v <= 128.0 / 255.0 + 1e-4), "grayscale: {:?}", gray.px[2]); + let mut rgb = input.clone(); + adjust::apply_in(&adj, &mut rgb, adjust::Transfer::Gamma(1.0), Some(SampleType::F32), ColorMode::Rgb); + check(&rgb, "apply_in in RGB"); + // Infinite samples don't become NaN on the way out. + let mut odd = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[f32::INFINITY, f32::NEG_INFINITY, 0.5, 1.0]] }; + adjust::apply_depth(&adj, &mut odd, adjust::Transfer::Gamma(1.0), Some(SampleType::F32)); + assert!(odd.px[0].iter().all(|v| !v.is_nan()), "{:?}", odd.px[0]); +} + #[test] fn float_adjustments_preserve_hdr_range() { let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }]; diff --git a/crates/compose/src/uniform.rs b/crates/compose/src/uniform.rs index e9423d0e69..02bc4a4111 100644 --- a/crates/compose/src/uniform.rs +++ b/crates/compose/src/uniform.rs @@ -32,7 +32,8 @@ impl<'a> UniformAreas<'a> { return None; } let mut surfaces = Vec::new(); - for (_, _, l) in doc.walk() { + // Hidden layers are not composited: one with an effect must not rule the shortcut out. + for l in crate::visible_layers(&doc.layers) { let effects = l.effects.enabled && !l.effects.items.is_empty(); if effects || l.vector_mask.is_some() || l.video.is_some() || l.blend == BlendMode::Dissolve { return None; diff --git a/crates/doc/src/adjust.rs b/crates/doc/src/adjust.rs index 855cd1f399..e197236a4a 100644 --- a/crates/doc/src/adjust.rs +++ b/crates/doc/src/adjust.rs @@ -281,10 +281,11 @@ pub enum Adjustment { relative: bool, adjustments: [[f32; 4]; 9], }, - /// A PSD adjustment we can't evaluate yet; preserved raw for round-trip. + /// A PSD adjustment we can't evaluate yet; preserved raw for round-trip, shared between + /// document snapshots (#4535). Unsupported { psd_key: String, - raw: Vec, + raw: std::sync::Arc<[u8]>, }, } diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 7f0e34e9b0..4e3f32a97f 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -37,6 +37,16 @@ pub struct Gradient { pub offset: (f32, f32), } +/// Editable colour stops behind a Gradient Overlay's baked render stops. +/// Older documents have no interpolation record and keep their existing appearance. +/// Writers must rebake `gradient.stops` from this record, or clear it when replacing the curve. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientInterpolation { + pub method: String, + pub smoothness: f32, + pub stops: Vec<(f32, Color)>, +} + impl Default for Gradient { fn default() -> Self { Gradient { @@ -278,6 +288,8 @@ pub enum Effect { common: FxCommon, gradient: Gradient, dither: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + interpolation: Option, }, PatternOverlay { common: FxCommon, @@ -313,7 +325,12 @@ impl Effect { Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint(&mut g.paint), Effect::Stroke(s) => paint(&mut s.paint), Effect::ColorOverlay { color, .. } => cc(color), - Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)), + Effect::GradientOverlay { gradient, interpolation, .. } => { + gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)); + if let Some(source) = interpolation { + source.stops.iter_mut().for_each(|s| cc(&mut s.1)); + } + } Effect::Satin(s) => cc(&mut s.color), Effect::BevelEmboss(b) => { cc(&mut b.highlight_color); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9e79d10da2..6ec9bc67e3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -846,7 +846,10 @@ pub type PsdGlobalBlock = ([u8; 4], [u8; 4], Arc>); #[derive(Clone, Debug, Default, PartialEq)] pub struct Metadata { - pub xmp: Option, + /// The XMP packet as stored, kept as text so properties PhotoCraft doesn't model survive. + /// Shared like the other payloads here: cloning a document or a history state never copies + /// it (#4535). + pub xmp: Option>, pub exif: Option>>, /// Free-form image text (PNG tEXt/zTXt/iTXt, TIFF ASCII tags), including duplicate keys. pub text: Vec<(String, String)>, diff --git a/crates/doc/src/mode.rs b/crates/doc/src/mode.rs index 9722ce5b15..7426705001 100644 --- a/crates/doc/src/mode.rs +++ b/crates/doc/src/mode.rs @@ -57,8 +57,9 @@ impl DuotoneInk { pub struct Duotone { pub inks: Vec, /// The PSD colour-mode data this came from, written back unchanged while the inks are unedited. + /// Shared between document snapshots (#4535). #[serde(default)] - pub psd_raw: Option>, + pub psd_raw: Option>, } impl DuotoneInk { diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index 7fb6ba3ad5..9e66752833 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -241,12 +241,22 @@ pub fn auto_slices(canvas: Rect, taken: &[Rect]) -> Vec { } } // Merge vertically adjacent runs with the same columns. + // `open` finds the slice a run continues by its columns and bottom edge: searching the output + // for each run is quadratic in the runs, and bars crossing in a grid leave a million of them. let mut out: Vec = Vec::new(); + let mut open: std::collections::HashMap<(i32, i32, i32), usize> = std::collections::HashMap::new(); for r in runs { - if let Some(prev) = out.iter_mut().find(|p| p.x0 == r.x0 && p.x1 == r.x1 && p.y1 == r.y0) { - prev.y1 = r.y1; - } else { - out.push(r); + match open.remove(&(r.x0, r.x1, r.y0)) { + Some(i) => { + if let Some(prev) = out.get_mut(i) { + prev.y1 = r.y1; + } + open.insert((r.x0, r.x1, r.y1), i); + } + None => { + open.insert((r.x0, r.x1, r.y1), out.len()); + out.push(r); + } } } out diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 900e487f07..a4b9ca2fa3 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -222,7 +222,8 @@ pub struct ParagraphRun { } /// Point text grows from its anchor (text-space origin = first baseline at the anchor); -/// paragraph (box) text wraps inside a rectangle; path text runs along a curve. +/// paragraph (box) text wraps inside a rectangle; path text runs along a curve; area text +/// wraps inside a closed path. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum TextShape { #[default] @@ -234,6 +235,10 @@ pub enum TextShape { /// curve. `start` is the arc-length offset (px) of the text origin along the path; `flip` /// puts the text on the other side of the path. Path { path: super::vector::Subpath, start: f32, flip: bool }, + /// Area type: paragraph text that wraps inside the closed subpaths of `path` (text-space + /// pixels, before the layer transform), combined with their path operations and fill rule. + /// Text that doesn't fit is hidden, as in a text box. + Area { path: super::vector::Path }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6b710eab93..0db3487c56 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -38,10 +38,13 @@ craft-print = { workspace = true } # Photoshop `.kys` shortcut sets (`edit.keyboardShortcuts` `importKys`); already in the tree # through photocraft-ui-egui and usvg. -roxmltree = "0.20" +roxmltree = "0.21" +# Release tags use the same semantic versions as Cargo. +semver = "1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +chrono = { workspace = true } # `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a # file is that file (std's `MetadataExt::file_index` is unstable). @@ -59,6 +62,7 @@ photocraft-ipp = { workspace = true } photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. photocraft-raw = { workspace = true, features = ["testgen"] } +photocraft-testkit = { workspace = true } # WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception). wat = "1.228" # Synthetic multi-part EXR fixtures for the parts-commands tests. diff --git a/crates/engine/examples/bench_cell_filters.rs b/crates/engine/examples/bench_cell_filters.rs new file mode 100644 index 0000000000..222d637c5c --- /dev/null +++ b/crates/engine/examples/bench_cell_filters.rs @@ -0,0 +1,99 @@ +//! Time the actual menu command on a 24 MP layer, including history commit. +//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- crystallize 10 5 8 +//! Arguments: crystallize|pointillize, cell size, repeats (1..9), depth (8|16|32), layer|background. +//! Run the same example at the base and changed revisions; compare the output hashes. +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_engine::Session; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::json; +use std::time::Instant; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let case = args.first().map(String::as_str).unwrap_or("crystallize"); + let cell: f32 = args.get(1).map(|v| v.parse()).transpose()?.unwrap_or(10.0); + let reps: usize = args.get(2).map(|v| v.parse()).transpose()?.unwrap_or(5); + let depth: u32 = args.get(3).map(|v| v.parse()).transpose()?.unwrap_or(8); + if !matches!(case, "crystallize" | "pointillize") || !(3.0..=300.0).contains(&cell) || !(1..=9).contains(&reps) { + return Err("case, cell size or repeat count out of range".into()); + } + let kind = args.get(4).map(String::as_str).unwrap_or("layer"); + if !matches!(kind, "layer" | "background") { + return Err("kind must be layer or background".into()); + } + let (w, h) = (6000, 4000); + let bounds = Rect::new(0, 0, w, h); + let sample = match depth { + 8 => SampleType::U8, + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => return Err("invalid depth".into()), + }; + let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + for tile in bounds.tiles() { + let r = tile.rect().intersect(&bounds); + let mut data = Vec::new(); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b); + for c in 0..4 { + let value = seed.rotate_left(c * 7).wrapping_add(c * 71) % 65536; + data.push(if c == 3 && kind == "background" { + 1.0 + } else if c == 3 && seed.is_multiple_of(5) { + 0.0 + } else { + value as f32 / 65535.0 + }); + } + } + } + source.write_region(r, &data); + } + let id = format!("filter.pixelate.{case}"); + let mut times = Vec::new(); + let hash = |surface: &Surface| { + let mut hash = blake3::Hasher::new(); + for tile in bounds.tiles() { + hash.update(&surface.to_interleaved(tile.rect().intersect(&bounds))); + } + hash.finalize() + }; + let source_hash = hash(&source); + let mut output_hash = None; + for run in 0..reps { + let mut s = Session::new(); + s.execute("file.new", json!({"width":w,"height":h,"depth":depth,"background":if kind == "background" { "white" } else { "transparent" }}))?; + s.edit("fixture", |doc, _| { + let layer = doc.layers.first_mut().ok_or(photocraft_engine::EngineError::Other("no layer".into()))?; + let surface = layer.surface_mut().ok_or(photocraft_engine::EngineError::Other("no surface".into()))?; + *surface = source.clone(); + Ok(()) + })?; + let start = Instant::now(); + s.execute(&id, json!({"cellSize":cell,"seed":37,"background":[1.0,1.0,1.0,1.0]}))?; + let seconds = start.elapsed().as_secs_f64(); + println!("COMMAND {case} cell={cell} depth={depth} kind={kind} run={} seconds={seconds:.6}", run + 1); + times.push(seconds); + if run + 1 == reps { + let doc = s.active().ok_or("no document")?; + let layer = doc.doc.layers.first().ok_or("no layer")?; + let surface = layer.surface().ok_or("no surface")?; + let filtered_hash = hash(surface); + s.execute("edit.undo", json!({}))?; + let restored = s.active().and_then(|d| d.doc.layers.first()).and_then(|l| l.surface()).ok_or("no undo surface")?; + if hash(restored) != source_hash { + return Err("undo changed source pixels".into()); + } + s.execute("edit.redo", json!({}))?; + let redone = s.active().and_then(|d| d.doc.layers.first()).and_then(|l| l.surface()).ok_or("no redo surface")?; + if hash(redone) != filtered_hash { + return Err("redo changed filtered pixels".into()); + } + output_hash = Some(filtered_hash); + } + } + times.sort_by(f64::total_cmp); + println!("MEDIAN {} HASH {}", times.get(times.len() / 2).ok_or("no runs")?, output_hash.ok_or("no output hash")?); + Ok(()) +} diff --git a/crates/engine/examples/bench_live_dab_release.rs b/crates/engine/examples/bench_live_dab_release.rs new file mode 100644 index 0000000000..f6935d3210 --- /dev/null +++ b/crates/engine/examples/bench_live_dab_release.rs @@ -0,0 +1,87 @@ +//! Release cost of a live sequential-dab stroke on a 24 MP layer, in release mode: the commit +//! rendering the stroke again (no offer) against taking the live pixels (`offer_live_dab`, which +//! runs the finishing dabs, plus the commit). Also the live stroke's per-frame cost while drawn. +use std::time::Instant; + +use photocraft_engine::{EngineError, Session, paint::StrokePoint, retouch_cmds::LiveDab}; +use photocraft_geom::Rect; +use serde_json::json; + +const FRAMES: usize = 60; + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1e3 +} + +fn run() -> Result<(), Box> { + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000,"depth":8,"mode":"rgb"}))?; + s.execute("layer.new.layer", json!({}))?; + s.edit("stripes", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?; + let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?; + for x in (0..6000).step_by(64) { + let c = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &c); + } + Ok(()) + })?; + // ~1000 px/s at 60 fps: 16 px of drag per frame, in four samples. + let pts: Vec<[f64; 2]> = std::iter::once([1000.0, 2000.0]) + .chain((1..=FRAMES).flat_map(|f| (1..=4).map(move |k| [1000.0 + (f as f64 - 1.0) * 16.0 + k as f64 * 4.0, 2000.0]))) + .collect(); + for cmd in ["paint.blur", "paint.smudge", "paint.dodge"] { + for size in [50, 150, 300, 600] { + let p = |v: &[[f64; 2]]| json!({"points": v, "size": size, "hardness": 0, "strength": 50, "spacing": 25}); + let draw = |s: &Session| -> Result<(LiveDab, Vec), Box> { + let mut live = LiveDab::begin(s, cmd, &p(&pts[..1]))?; + let mut frames = Vec::new(); + for batch in pts[1..].chunks(4) { + let b: Vec<_> = batch.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + let t = Instant::now(); + live.push(&b)?; + frames.push(ms(t)); + } + Ok((live, frames)) + }; + let (mut before, mut after) = (Vec::new(), Vec::new()); + let mut frames = Vec::new(); + for _ in 0..3 { + let (_, f) = draw(&s)?; + frames.extend(f); + let t = Instant::now(); + s.execute(cmd, p(&pts))?; + before.push(ms(t)); + s.execute("edit.undo", json!({}))?; + let (live, _) = draw(&s)?; + let t = Instant::now(); + s.offer_live_dab(live)?; + let r = s.execute(cmd, p(&pts))?; + after.push(ms(t)); + if r.get("fromLive").is_none() { + return Err("the commit didn't take the live pixels".into()); + } + s.execute("edit.undo", json!({}))?; + } + before.sort_by(f64::total_cmp); + after.sort_by(f64::total_cmp); + frames.sort_by(f64::total_cmp); + let total: f64 = frames.iter().sum::() / 3.0; + println!( + "{cmd:12} size={size:4} release: render again={:7.1} ms take live={:6.1} ms | live frames: max={:6.2} ms total={:7.1} ms", + before[1], + after[1], + frames.last().copied().unwrap_or(0.0), + total + ); + } + } + Ok(()) +} + +fn main() { + if let Err(e) = run() { + eprintln!("{e}"); + std::process::exit(1); + } +} diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 6f559a5659..7f9b52ff85 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -100,6 +100,12 @@ pub struct ActionState { /// Number of steps executed by the current top-level playback tree. #[serde(skip)] pub playback_steps: u64, + /// How many scripts (`file.scripts.browse`) are running inside one another. + #[serde(skip)] + pub script_depth: u8, + /// Steps run by the current top-level script and everything it calls. + #[serde(skip)] + pub script_steps: u64, /// Bumped whenever `list` changes, so the preset store can skip unchanged writes. #[serde(skip)] pub rev: u64, @@ -150,6 +156,14 @@ pub fn save_target_closed(s: &Session, id: &str, target: Option bool { + // Inside a script the step also counts against the script's total: each top-level playback + // resets its own counter, so a script playing actions in a row would never reach the limit. + if s.actions.script_depth > 0 { + if s.actions.script_steps >= crate::automate_cmds::MAX_SCRIPT_STEPS { + return false; + } + s.actions.script_steps += 1; + } if s.actions.playback_steps >= MAX_PLAYBACK_STEPS { false } else { @@ -1167,6 +1181,173 @@ mod tests { } } + /// #7845: dragging a layer in the Layers panel while recording keeps the drop target by name, + /// so the action plays on another document (whose layer ids differ) instead of failing with + /// "no such layer". The reporter's steps, replayed on a new document. + #[test] + fn recorded_layer_drag_replays_on_another_document() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap(); + s.execute("actions.record", json!({"name": "Retouch"})).unwrap(); + s.execute("layer.stampVisible", json!({})).unwrap(); + let layer1 = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layerViaCopy", json!({})).unwrap(); + let layer2 = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.select", json!({"layer": layer1.0, "mode": "replace"})).unwrap(); + s.execute("filter.noise.median", json!({"radius": 6})).unwrap(); + s.execute("layer.new.layerViaCopy", json!({})).unwrap(); + let copy = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.moveTo", json!({"layer": copy.0, "target": layer2.0, "position": "above"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let (id, step) = s.actions.list[0].steps.last().unwrap(); + assert_eq!((id.as_str(), step), ("layer.moveTo", &json!({"target": "Layer 1 copy", "position": "above"}))); + let names = |s: &Session| s.active().unwrap().doc.layers.iter().rev().map(|l| l.name.clone()).collect::>(); + let recorded = names(&s); + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + replay.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + assert_eq!(names(&replay), recorded, "{depth}: the same stack"); + } + // Several layers of the drop target's name: the recorded occurrence picks the right one. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + let upper = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Mover"})).unwrap(); + let mover = s.active().unwrap().active_layer.unwrap(); + s.execute("actions.record", json!({"name": "Drop"})).unwrap(); + s.execute("layer.moveTo", json!({"layer": mover.0, "target": upper.0, "position": "below"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 1, "position": "below"})); + // The moved layer shares the target's name: the occurrence counts the other "Twin"s only, + // so it means the same layer before the move (on replay) as after it (when recorded). + let build = |s: &mut Session| { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + for _ in 0..3 { + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + } + s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>() + }; + let mut s = Session::new(); + let ids = build(&mut s); + let (lowest, top) = (ids[1], ids[3]); + s.execute("actions.record", json!({"name": "Twins"})).unwrap(); + s.execute("layer.moveTo", json!({"layer": top.0, "target": lowest.0, "position": "below"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 0, "position": "below"})); + let order = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::>(); + let want: Vec<_> = order(&s).iter().map(|id| ids.iter().position(|x| x == id).unwrap()).collect(); + let saved = serde_json::to_string(&s.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + let ids2 = build(&mut replay); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let got: Vec<_> = order(&replay).iter().map(|id| ids2.iter().position(|x| x == id).unwrap()).collect(); + assert_eq!(got, want, "the same layer became the lowest"); + // A moved group holding a same-named layer: its contents don't count either, or moving the + // group past another "Twin" would shift the occurrence between recording and replay. + let build_group = |s: &mut Session| { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + let top = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap(); + (group, top) + }; + let mut g = Session::new(); + let (group, top) = build_group(&mut g); + g.execute("actions.record", json!({"name": "Group"})).unwrap(); + g.execute("layer.moveTo", json!({"layer": group.0, "target": top.0, "position": "above"})).unwrap(); + g.execute("actions.stop", json!({})).unwrap(); + let recorded = g.actions.list[0].steps[0].1.clone(); + let saved = serde_json::to_string(&g.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + let (group2, _) = build_group(&mut replay); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{recorded}: {result}"); + let top_layer = |s: &Session| s.active().unwrap().doc.layers.last().map(|l| l.id); + assert_eq!(top_layer(&replay), Some(group2), "{recorded}: the group went to the top"); + // Several moved layers named like the target are left out too, so the name is unambiguous. + let mut m = Session::new(); + m.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let a = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + let b = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + let target = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + m.execute("layer.moveTo", json!({"layers": [a.0, b.0], "target": "Twin", "position": "above"})).unwrap(); + assert_eq!(m.active().unwrap().doc.layers.last().map(|l| l.id), Some(b), "both moved above the third Twin"); + assert_eq!(m.active().unwrap().doc.layers.iter().rev().nth(2).map(|l| l.id), Some(target)); + // An ⌥-drag copy of the selected layer beside itself replays by name too. + let mut c = Session::new(); + c.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let src = photocraft_doc::LayerId(c.execute("layer.new.layer", json!({"name": "Solo"})).unwrap()["layer"].as_u64().unwrap()); + c.execute("actions.record", json!({"name": "Copy"})).unwrap(); + c.execute("layer.moveTo", json!({"layer": src.0, "target": src.0, "position": "above", "copy": true})).unwrap(); + c.execute("actions.stop", json!({})).unwrap(); + assert_eq!(c.actions.list[0].steps[0].1, json!({"target": "Solo", "position": "above", "copy": true})); + let saved = serde_json::to_string(&c.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + replay.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + replay.execute("layer.new.layer", json!({"name": "Solo"})).unwrap(); + let before = replay.active().unwrap().doc.layers.len(); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(replay.active().unwrap().doc.layers.len(), before + 1, "one copy"); + // Copying a group holding a layer named like the target: the new copy's contents (made + // before the step is recorded) don't count, so the occurrence matches the replay. + let build_copy = |s: &mut Session| { + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = s.active().unwrap().active_layer.unwrap(); + let a = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + let b = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap(); + (group, a, b) + }; + let mut gc = Session::new(); + let (group, a, _) = build_copy(&mut gc); + gc.execute("actions.record", json!({"name": "Copy group"})).unwrap(); + // Below `a`: the copy's "Twin" then comes before `a` in document order. + gc.execute("layer.moveTo", json!({"layer": group.0, "target": a.0, "position": "below", "copy": true})).unwrap(); + gc.execute("actions.stop", json!({})).unwrap(); + let saved = serde_json::to_string(&gc.actions.list).unwrap(); + let mut replay = Session::new(); + replay.actions.list = serde_json::from_str(&saved).unwrap(); + let (_, a2, _) = build_copy(&mut replay); + let result = replay.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let layers = &replay.active().unwrap().doc.layers; + let at = layers.iter().position(|l| l.id == a2).unwrap(); + let below = at.checked_sub(1).and_then(|i| layers.get(i)).map(|l| l.name.as_str()); + assert_eq!(below, Some("Group 1 copy"), "the copy landed below the same Twin"); + // A drag of several layers keeps its ids (not covered by name). + s.execute("actions.record", json!({"name": "Several"})).unwrap(); + s.execute("layer.moveTo", json!({"layers": [ids[2].0, ids[3].0], "target": ids[1].0, "position": "above"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[1].steps[0].1["target"], json!(ids[1].0)); + // Bad targets are errors, not panics. + for bad in [ + json!({"target": "Nobody"}), + json!({"target": "Twin"}), + json!({"target": "Twin", "targetOccurrence": 9}), + json!({"target": "Twin", "targetOccurrence": -1}), + json!({"target": true}), + ] { + assert!(s.execute("layer.moveTo", bad.clone()).is_err(), "{bad}"); + } + } + /// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain /// layer's recorded copy must still export to .atn, while an artboard's must be refused rather /// than exported as a copy beside the original. diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index f4e0694fa3..830d601b5f 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -355,7 +355,7 @@ pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option< } else { let mode = surf.format().mode; let adj = crate::adjust_params::from_params(kind, p, None, mode).ok()?; - crate::pixels::adjust_surface(&mut out, &adj, None, mode); + crate::pixels::adjust_surface(&mut out, &adj, None, mode).ok()?; } out.prune(); Some(out) diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index d4cc56b2bd..99e3c8a590 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -44,6 +44,14 @@ fn selected_pixel_layers(s: &Session) -> Vec { st.selected_layers().into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Raster(_)))).collect() } +/// Whether a lock keeps the layer `id` from being moved or warped: lock all, position or pixels. +/// The Background's own position lock doesn't count: like Free Transform, Auto-Align turns the +/// Background into a normal layer when it has to move it (Photoshop). +fn pinned(doc: &Document, id: LayerId) -> bool { + let l = doc.effective_locks(id); + l.all || l.pixels || (l.position && !doc.is_background(id)) +} + fn two_layers(s: &Session) -> std::result::Result<(), String> { s.active().ok_or("no document open")?; if selected_pixel_layers(s).len() < 2 { Err("select two or more pixel layers in the Layers panel".into()) } else { Ok(()) } @@ -63,7 +71,8 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { let reference = match p.get("reference").and_then(Value::as_u64) { Some(r) if ids.contains(&LayerId(r)) => LayerId(r), Some(r) => return Err(bad(cmd, format!("reference {r} is not a selected pixel layer"))), - None => ids[0], + // As in Photoshop, a locked layer stays put: it becomes the reference (#7080). + None => ids.iter().copied().find(|id| pinned(&doc, *id)).unwrap_or(ids[0]), }; let ref_idx = ids.iter().position(|i| *i == reference).unwrap_or(0); let area = doc.bounds(); @@ -100,6 +109,9 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { if moves.is_empty() { return Err(EngineError::Other("Auto-Align couldn't find enough matching detail between the layers".into())); } + if let Some(l) = moves.iter().find(|&&i| pinned(&doc, ids[i])).and_then(|&i| doc.layer(ids[i])) { + return Err(EngineError::Other(format!("Auto-Align can't move the locked layer \"{}\"", l.name))); + } let warped: Vec<(LayerId, Surface, Option)> = moves .iter() .map(|&i| { @@ -122,6 +134,8 @@ fn auto_align(s: &mut Session, p: &Value) -> Result { .collect::>()?; s.edit("Auto-Align Layers", |doc, _| { for (id, px, mask) in &warped { + // Photoshop turns the Background into a normal layer before moving it. + crate::extra_cmds::background_to_layer(doc, *id); let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; if let Some(surf) = l.surface_mut() { *surf = px.clone(); @@ -331,7 +345,7 @@ pub fn specs() -> Vec { spec!( "edit.autoAlignLayers", "Auto-Align Layers…", - r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##, + r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=the locked selected layer, else the bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##, auto_align ), spec!( @@ -350,8 +364,15 @@ mod tests { /// Deterministic texture written into a layer at offset (dx, dy). fn texture_layer(s: &mut Session, name: &str, dx: i32, dy: i32) -> LayerId { s.execute("layer.new.layer", json!({"name": name})).unwrap(); - s.edit("tex", |doc, active| { - let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + paint_texture(s, id, dx, dy); + id + } + + /// Writes the deterministic texture into layer `id` at offset (dx, dy). + fn paint_texture(s: &mut Session, id: LayerId, dx: i32, dy: i32) { + s.edit("tex", |doc, _| { + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); let mut seed = 99u64; let mut rnd = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); @@ -367,7 +388,6 @@ mod tests { Ok(()) }) .unwrap(); - s.active().unwrap().active_layer.unwrap() } fn select(s: &mut Session, ids: &[LayerId]) { @@ -411,6 +431,86 @@ mod tests { assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err()); } + /// As in Photoshop, a locked layer stays where it is: it becomes the reference and the + /// others move onto it (#7080). + #[test] + fn auto_align_keeps_a_locked_layer_as_the_reference() { + for lock in ["all", "position", "pixels"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 240, "background": "transparent"})).unwrap(); + let a = texture_layer(&mut s, "A", 20, 20); + let b = texture_layer(&mut s, "B", 31, 13); + s.edit("lock", |doc, _| { + doc.layer_mut(b).unwrap().locks.set(lock, true); + Ok(()) + }) + .unwrap(); + select(&mut s, &[a, b]); + let r = s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).unwrap(); + assert_eq!(r["reference"], b.0, "{lock}: {r}"); + let doc = &s.active().unwrap().doc; + let bb = doc.layer(b).unwrap().surface().unwrap().content_bounds(); + assert_eq!((bb.x0, bb.y0), (31, 13), "{lock}: the locked layer stays put"); + let ab = doc.layer(a).unwrap().surface().unwrap().content_bounds(); + assert!((ab.x0 - 31).abs() <= 1 && (ab.y0 - 13).abs() <= 1, "{lock}: {ab:?} {r}"); + // An explicit reference that would move the locked layer is refused. + s.undo(); + assert!(s.execute("edit.autoAlignLayers", json!({"projection": "reposition", "reference": a.0})).is_err()); + // Two locked layers: nothing may move. + s.edit("lock", |doc, _| { + doc.layer_mut(a).unwrap().locks.set(lock, true); + Ok(()) + }) + .unwrap(); + assert!(s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).is_err()); + let bb = s.active().unwrap().doc.layer(a).unwrap().surface().unwrap().content_bounds(); + assert_eq!((bb.x0, bb.y0), (20, 20)); + } + } + + /// The Background doesn't block Auto-Align: when a projection reprojects every layer + /// (Cylindrical, Spherical, or Auto choosing one), or another layer is the reference, it + /// becomes a normal layer ("Layer 0") and moves, as in Photoshop and as Free Transform does. + #[test] + fn auto_align_moves_the_background_as_a_normal_layer() { + let setup = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 240, "background": "white"})).unwrap(); + let bg = s.active().unwrap().doc.background().unwrap().id; + paint_texture(&mut s, bg, 20, 20); + let b = texture_layer(&mut s, "B", 31, 13); + select(&mut s, &[bg, b]); + (s, bg, b) + }; + for projection in ["cylindrical", "spherical", "auto"] { + let (mut s, bg, b) = setup(); + let r = s.execute("edit.autoAlignLayers", json!({"projection": projection})).unwrap_or_else(|e| panic!("{projection}: {e}")); + let doc = &s.active().unwrap().doc; + let moved = r["aligned"].as_array().unwrap().iter().any(|a| a["layer"] == bg.0); + assert_eq!(moved, projection != "auto" || r["layout"] != "perspective", "{projection}: {r}"); + assert_eq!(doc.is_background(bg), !moved, "{projection}: a moved Background is a normal layer: {r}"); + if moved { + assert_eq!(doc.layer(bg).unwrap().name, "Layer 0", "{projection}"); + } + assert!(doc.layer(b).is_some(), "{projection}"); + s.undo(); + assert!(s.active().unwrap().doc.is_background(bg), "{projection}: undo restores the Background"); + } + // Planar, with the other layer as the reference: the Background moves onto it. + let (mut s, bg, b) = setup(); + s.execute("edit.autoAlignLayers", json!({"projection": "reposition", "reference": b.0})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(!doc.is_background(bg)); + // Default reference: the Background (bottom) stays put and stays the Background. + let (mut s, bg, b) = setup(); + let r = s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).unwrap(); + assert_eq!(r["reference"], bg.0, "{r}"); + let doc = &s.active().unwrap().doc; + assert!(doc.is_background(bg)); + let bb = doc.layer(b).unwrap().surface().unwrap().content_bounds(); + assert!((bb.x0 - 20).abs() <= 1 && (bb.y0 - 20).abs() <= 1, "{bb:?} {r}"); + } + /// The moving layer's mask is warped as a mask: one grey channel whose outside keeps its /// default (reveal-all stays revealed, hide-all stays hidden), for planar and mesh warps. #[test] diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 92b630aa44..8932295d52 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -231,18 +231,7 @@ pub fn now_iso() -> String { #[cfg(not(target_arch = "wasm32"))] { let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); - let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) + iso_from_unix(secs) } #[cfg(target_arch = "wasm32")] { @@ -250,6 +239,29 @@ pub fn now_iso() -> String { } } +/// `YYYY-MM-DDTHH:MM:SSZ` for Unix seconds `secs` (UTC, proleptic Gregorian). A time outside +/// chrono's year range still converts (the calendar repeats every 400 years, so the instant is +/// moved into range by whole cycles and only the year is carried, matching the old hand-rolled +/// code), never a silent reset to the epoch. chrono is the only calendar logic. +#[cfg(not(target_arch = "wasm32"))] +fn iso_from_unix(secs: i64) -> String { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", d.and_time(t).format("%m-%dT%H:%M:%SZ")) + } + _ => "1970-01-01T00:00:00Z".to_string(), // unreachable + } +} + fn round4(v: f64) -> f64 { (v * 10_000.0).round() / 10_000.0 } diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index f9138e8b65..c1184f2a4c 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -535,3 +535,20 @@ fn count_add_keeps_first_group_creation_and_targets_the_requested_group() { let r = s.execute("count.add", json!({"x": 6, "y": 7})).unwrap(); assert_eq!(r["groups"][1]["points"], json!([[6.0, 7.0]])); } + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn iso_from_unix_matches_the_old_cycle_conversion_including_beyond_chrono() { + use super::iso_from_unix; + assert_eq!(iso_from_unix(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso_from_unix(951_782_400), "2000-02-29T00:00:00Z"); + assert_eq!(iso_from_unix(1_791_446_357), "2026-10-08T07:59:17Z"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(iso_from_unix(253_402_300_800), "10000-01-01T00:00:00Z"); + // Beyond chrono's year range the old hand-rolled code still converted; pin that output + // (never the epoch) in both directions and at the i64 extremes. + assert_eq!(iso_from_unix(10_000_000_000_000), "318857-05-20T17:46:40Z"); + assert_eq!(iso_from_unix(-10_000_000_000_000), "-314918-08-13T06:13:20Z"); + assert_eq!(iso_from_unix(i64::MAX), "292277026596-12-04T15:30:07Z"); + assert_eq!(iso_from_unix(i64::MIN), "-292277022657-01-27T08:29:52Z"); +} diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index f166c239b3..6f43e5ef70 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -306,33 +306,51 @@ fn path_value(text: &str) -> D { /// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels. const BLEND_MODES: &[(&str, &str)] = &[ ("Nrml", "Normal"), + ("normal", "Normal"), ("Dslv", "Dissolve"), + ("dissolve", "Dissolve"), ("Drkn", "Darken"), + ("darken", "Darken"), ("Mltp", "Multiply"), + ("multiply", "Multiply"), ("CBrn", "Color Burn"), + ("colorBurn", "Color Burn"), ("linearBurn", "Linear Burn"), ("darkerColor", "Darker Color"), ("Lghn", "Lighten"), + ("lighten", "Lighten"), ("Scrn", "Screen"), + ("screen", "Screen"), ("CDdg", "Color Dodge"), + ("colorDodge", "Color Dodge"), ("linearDodge", "Linear Dodge (Add)"), ("lighterColor", "Lighter Color"), ("Ovrl", "Overlay"), + ("overlay", "Overlay"), ("SftL", "Soft Light"), + ("softLight", "Soft Light"), ("HrdL", "Hard Light"), + ("hardLight", "Hard Light"), ("vividLight", "Vivid Light"), ("linearLight", "Linear Light"), ("pinLight", "Pin Light"), ("hardMix", "Hard Mix"), ("Dfrn", "Difference"), + ("difference", "Difference"), ("Xclu", "Exclusion"), + ("exclusion", "Exclusion"), ("blendSubtraction", "Subtract"), ("blendDivide", "Divide"), ("H", "Hue"), + ("hue", "Hue"), ("Strt", "Saturation"), + ("saturation", "Saturation"), ("Clr", "Color"), + ("color", "Color"), ("Lmns", "Luminosity"), + ("luminosity", "Luminosity"), ("PsTh", "Pass Through"), + ("passThrough", "Pass Through"), ]; fn blend_label(ps: &str) -> Option<&'static str> { @@ -377,13 +395,11 @@ fn channel_key(d: &Descriptor) -> Option> { }) } +/// A Levels or Curves entry's channel: `Chnl.Cmps` for the composite, as Photoshop records it. +/// Photoshop silently skips a Set whose composite is `Ordn.Trgt` (#8543). fn channel_ref(key: Option<&str>) -> D { let value = key.and_then(|k| lookup_back(CHANNELS, k)); - D::Reference(vec![ReferenceItem::Enumerated { - class: class("Chnl"), - type_id: code(if value.is_some() { "Chnl" } else { "Ordn" }), - value: code(value.unwrap_or("Trgt")), - }]) + D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(value.unwrap_or("Cmps")) }]) } // ------------------------------------------------------------------ field tables @@ -766,46 +782,32 @@ fn selective_from(d: &Descriptor) -> Option { } fn selective_to(p: &Value) -> Option { - // Each range, as the command reads it (`adjust_cmds::selective_from_params`): the array form - // `"reds": [c, m, y, k]` first, then the scalar form, which names one range in `"colors"` and - // gives the channels it changes. Writing only the arrays dropped the scalar form's values, and - // the step exported as an adjustment that changes nothing (#7672). - let mut rows: Vec> = vec![None; SELECTIVE_RANGES.len()]; - for (row, (_, name)) in rows.iter_mut().zip(SELECTIVE_RANGES) { - if let Some(a) = p.get(*name).and_then(Value::as_array) { - let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0); - *row = Some([v(0), v(1), v(2), v(3)]); - } - } - if let Some(named) = jstr(p, "colors") { - let i = SELECTIVE_RANGES.iter().position(|(_, name)| *name == named)?; - let row = rows.get_mut(i)?; - let mut v = row.unwrap_or([0.0; 4]); - for (slot, key) in v.iter_mut().zip(["cyan", "magenta", "yellow", "black"]) { - // A channel that is given but is not a number is an error in the command - // (`adjust_cmds::selective_from_params`), so a step carrying one cannot be written - // either: ignoring it would export a different adjustment than the one recorded. - // `null` counts as absent there, and so here. - match p.get(key) { - None | Some(Value::Null) => {} - Some(_) => *slot = jnum(p, key)?, - } - } - *row = Some(v); - } + // Match command execution: scalar components override only the selected range, `relative` + // takes precedence over `method`, and invalid values must not become a different edit. + let photocraft_doc::Adjustment::SelectiveColor { relative, adjustments } = crate::adjust_cmds::selective_from_params(p, None).ok()? else { + return None; + }; let mut list = Vec::new(); - for (row, (ps, _)) in rows.iter().zip(SELECTIVE_RANGES) { - if let Some(v) = row { + for (name, row) in crate::adjust_cmds::RANGES.into_iter().zip(adjustments) { + if p.get(name).is_some_and(|v| !v.is_null()) || jstr(p, "colors") == Some(name) { + let ps = lookup_back(SELECTIVE_RANGES, name)?; + let [cyan, magenta, yellow, black] = row.map(f64::from); list.push(D::Descriptor( - desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v[0])).with("Mgnt", pct(v[1])).with("Yllw", pct(v[2])).with("Blck", pct(v[3])), + desc("ClrC") + .with("Clrs", enm("Clrs", ps)) + .with("Cyn ", pct(cyan)) + .with("Mgnt", pct(magenta)) + .with("Yllw", pct(yellow)) + .with("Blck", pct(black)), )); } } - let mut d = desc("SlcC").with("presetKind", enm("presetKindType", "presetKindCustom")); - if let Some(m) = jstr(p, "method") { - d = d.with("Mthd", enm("CrcM", if m == "absolute" { "Absl" } else { "Rltv" })); - } - Some(d.with("ClrC", D::List(list))) + Some( + desc("SlcC") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("Mthd", enm("CrcM", if relative { "Rltv" } else { "Absl" })) + .with("ClrC", D::List(list)), + ) } /// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`. @@ -892,14 +894,23 @@ const ADJUSTMENTS: &[Adjustment] = &[ adj_fields!("Vbrn", "vibrance", &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]), adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]), Adjustment { class: "PhtF", suffix: "photoFilter", from: |d| fields_from(d, PHOTO_FILTER_FIELDS).map(Value::Object), to: photo_filter_to }, - adj_fields!("BlnW", "blackWhite", BW), + adj_fields!("BanW", "blackWhite", BW), Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to }, Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to }, Adjustment { class: "GdMp", suffix: "gradientMap", from: gradient_map_from, to: gradient_map_to }, ]; +/// Adjustment classes PhotoCraft once exported, with the class Photoshop uses. Measured in +/// Photoshop (#8481): a Make whose Black & White type is `BlnW` fails ("the command Make is not +/// currently available"), `BanW` runs. Import keeps accepting them so actions saved before still +/// load; export never writes them. +const LEGACY_CLASSES: &[(&str, &str)] = &[("BlnW", "BanW")]; + +/// The adjustment with Photoshop class `c`, or the one an old PhotoCraft class stands for. fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { - ADJUSTMENTS.iter().find(|a| a.class.trim_end() == c.trim_end()) + let c = c.trim_end(); + let c = LEGACY_CLASSES.iter().find(|(old, _)| *old == c).map_or(c, |(_, class)| class); + ADJUSTMENTS.iter().find(|a| a.class.trim_end() == c) } const PHOTO_FILTER_FIELDS: &[F] = &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]; @@ -1661,7 +1672,37 @@ fn set_to(id: &str, p: &Value) -> Option { "layer.setAdjustment" => { // The adjustment kind isn't in the params: Photoshop needs it, so only steps that // name one (`kind`) export. - let a = adjustment_by_suffix(jstr(p, "kind")?)?; + let kind = jstr(p, "kind")?; + // `layer.setAdjustment` changes only the settings it names. Color Balance and + // Hue/Saturation descriptors carry every setting, filling absent ones with defaults, + // so a partial edit would replay as one that also resets the rest (#8531). + // Present and of the shape the writer reads: anything else it would write as 0. + let number = |p: &Value, k: &str| p.get(k).and_then(Value::as_f64).is_some_and(f64::is_finite); + let numbers = |p: &Value, keys: &[&str]| keys.iter().all(|k| number(p, k)); + let flag = |k: &str| p.get(k).is_some_and(Value::is_boolean); + let triplet = + |k: &str| p.get(k).and_then(Value::as_array).is_some_and(|a| a.len() == 3 && a.iter().all(|v| v.as_f64().is_some_and(f64::is_finite))); + let complete = match kind { + "colorBalance" => ["shadows", "midtones", "highlights"].iter().all(|k| triplet(k)) && flag("preserveLuminosity"), + // Every colour range written carries its own hue, saturation and lightness too. + "hueSaturation" => { + flag("colorize") + && numbers(p, &["hue", "saturation", "lightness"]) + && HUE_RANGES.iter().filter_map(|r| p.get(*r).filter(|v| !v.is_null())).all(|r| { + // Bounds, when given, are the four whole numbers `hue_entry` writes (as integers); + // otherwise it drops or rounds them. + let bounds = r.get("range").filter(|v| !v.is_null()).is_none_or(|v| { + v.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(|b| b.as_f64().is_some_and(|b| b.is_finite() && b.fract() == 0.0))) + }); + r.is_object() && numbers(r, &["hue", "saturation", "lightness"]) && bounds + }) + } + _ => true, + }; + if !complete { + return None; + } + let a = adjustment_by_suffix(kind)?; desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor((a.to)(p)?)) } _ => return None, @@ -1840,6 +1881,63 @@ fn open_to(_: &str, p: &Value) -> Option { Some(desc("null").with("null", path_value(jstr(p, "path")?))) } +/// Save As format classes (`As`) and the extensions PhotoCraft saves them under. The first +/// extension of a class is the one exported, and the first class of an extension is the one +/// written back. `WebPFormat` is Photoshop's built-in WebP (checked against Photoshop 2026); the +/// Google classes come from the older WebPShop plug-in and are only read. +const SAVE_FORMATS: &[(&str, &[&str])] = &[ + ("Pht3", &["psd"]), + ("PNGF", &["png"]), + ("JPEG", &["jpg", "jpeg"]), + ("TIFF", &["tif", "tiff"]), + ("BMPF", &["bmp"]), + ("WebPFormat", &["webp"]), + ("Google WebPShop", &["webp"]), + ("Google WebP", &["webp"]), +]; + +fn path_extension(path: &str) -> Option { + let name = path.rsplit(['/', '\\']).next()?; + let (stem, ext) = name.rsplit_once('.')?; + (!stem.is_empty() && !ext.is_empty()).then(|| ext.to_ascii_lowercase()) +} + +/// `save`: a plain Save, or a Save As (`As` format, `In` destination, `Cpy` As a Copy). +/// Only a destination that is a file of the recorded format imports: a folder (#5430), a missing +/// or unknown format or a mismatched extension stays a kept Photoshop step, so playback stops +/// before writing rather than saving over the played file (#7102). +fn save_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + if d.get("As ").is_none() && d.get("In ").is_none() { + return step("file.save", json!({})); + } + // A Save As needs a format descriptor to check the destination against: without one (or + // with a format that isn't a descriptor) the step is kept. + let Some(format) = get_obj(d, "As ") else { return unsupported }; + let Some(path) = d.get("In ").and_then(path_text) else { return unsupported }; + if get_bool(d, "DIDr") == Some(true) { + return unsupported; + } + let Some(ext) = path_extension(&path) else { return unsupported }; + let class = trimmed(&format.class_id); + let Some((_, exts)) = SAVE_FORMATS.iter().find(|(c, _)| *c == class) else { return unsupported }; + if !exts.contains(&ext.as_str()) { + return unsupported; + } + step(if get_bool(d, "Cpy ") == Some(true) { "file.saveACopy" } else { "file.saveAs" }, json!({"path": path})) +} + +fn save_to(id: &str, p: &Value) -> Option { + if id == "file.save" { + return Some(desc("null")); + } + let path = jstr(p, "path")?; + let ext = path_extension(path)?; + let (class, _) = SAVE_FORMATS.iter().find(|(_, exts)| exts.contains(&ext.as_str()))?; + let d = desc("null").with("As ", D::Descriptor(desc(class))).with("In ", path_value(path)); + Some(if id == "file.saveACopy" { d.with("Cpy ", D::Boolean(true)) } else { d }) +} + /// Photoshop's four-character condition codes that differ from the string ids. const CONDITION_CODES: &[(&str, &str)] = &[("Lyrs", "hasLayers")]; @@ -1851,7 +1949,8 @@ fn conditional_from(d: &Descriptor) -> Mapped { return Mapped::Unsupported { harmless: false }; } let branch = |key: &str| match get_ref(d, key).map(target) { - Some(Target::Name(n)) => Some(n), + // Clipped like the stored action names, so the load-time rename map finds it. + Some(Target::Name(n)) => Some(clip_name(&n)), _ => None, }; step("actions.conditional", json!({"if": condition, "then": branch("then"), "else": branch("else")})) @@ -1921,7 +2020,15 @@ fn transform_from(d: &Descriptor) -> Mapped { let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; // The resampling applies to a selection as much as to a layer (#7515). - let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")); + // Photoshop's bicubic variants are kept (the transforms render them as Bicubic, and they export + // back unchanged); Automatic and Preserve Details become the nearest bicubic choice. Dropping + // them fell back to the command's default, bilinear for a selection (#8697). + let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).and_then(|i| match i { + "bicubic" | "bilinear" | "nearest" | "bicubicSharper" | "bicubicSmoother" | "bicubicAutomatic" => Some(i), + "automatic" => Some("bicubicAutomatic"), + "preserveDetails" => Some("bicubic"), + _ => None, + }); match (ref_class(r).as_deref(), target(r)) { (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => { if let Some(i) = interpolation { @@ -2017,6 +2124,10 @@ fn apply_image_from(d: &Descriptor) -> Mapped { let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| lookup(CHANNELS, &v)) else { return unsupported }; source.insert("channel".into(), channel.into()); } + // A named (alpha) channel. + ReferenceItem::Name { class, name } if class.class_id.is("Chnl") => { + source.insert("channel".into(), name.to_string_lossy().into()); + } ReferenceItem::Name { class, name } if class.class_id.is("Lyr ") => { source.insert("layer".into(), name.to_string_lossy().into()); } @@ -2047,6 +2158,26 @@ fn apply_image_from(d: &Descriptor) -> Mapped { step("image.applyImage", p) } +/// Apply Image's source channel as a `Chnl` reference item. The dialog sends Channels-panel names +/// ("RGB", "Red", "Alpha 1"), which the command reads case-insensitively: the composite names map to +/// the composite, colour names to their enumeration, and any other name to a named (alpha) channel, +/// as Photoshop records it. +fn apply_image_channel(channel: &str) -> Option { + let l = channel.to_ascii_lowercase(); + let key = match l.as_str() { + "rgb" | "cmyk" | "lab" | "merged" => "composite", + "layermask" => "mask", + // Sources the command knows by keyword but Photoshop has no channel for. + "quickmask" | "quick mask" | "vectormask" | "vector mask" => return None, + other => other, + }; + Some(match lookup_back(SOURCE_CHANNELS, key).or_else(|| lookup_back(CHANNELS, key)) { + Some(chan) => ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(chan) }, + None if !channel.trim().is_empty() => ReferenceItem::Name { class: class("Chnl"), name: UnicodeString::new_nul(channel) }, + None => return None, + }) +} + fn apply_image_to(_: &str, p: &Value) -> Option { // The optional blending mask has no descriptor mapping: refuse it rather than drop it, // which would widen the edit to the whole layer. @@ -2057,10 +2188,19 @@ fn apply_image_to(_: &str, p: &Value) -> Option { if mask_channel.is_some_and(|c| !(c.is_null() || c == "none" || c == "")) { return None; } - let source = p.get("source").cloned().unwrap_or(json!({})); - let channel = jstr(&source, "channel").unwrap_or("composite"); - let chan = lookup_back(SOURCE_CHANNELS, channel).or_else(|| lookup_back(CHANNELS, channel))?; - let mut items = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(chan) }]; + // The Apply Image dialog sends flat `sourceLayer`/`sourceChannel`/`sourceInvert` fields; read + // them as the command does, or the export would quietly reset them to the merged composite. + let source = crate::channel_cmds::source_spec(p, "source", "source").unwrap_or(json!({})); + // A source from another open document has no event form here: skip the step, don't rewrite it. + if source.get("document").is_some_and(|d| !d.is_null()) { + return None; + } + // A layer id or channel index has no name to record: refuse it rather than write the composite. + let not_name = |k: &str| source.get(k).is_some_and(|v| !(v.is_null() || v.is_string())); + if !source.is_object() || not_name("layer") || not_name("channel") { + return None; + } + let mut items = vec![apply_image_channel(jstr(&source, "channel").unwrap_or("composite"))?]; match jstr(&source, "layer") { Some("merged") | None => items.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Mrgd") }), Some(name) => items.push(ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }), @@ -2329,7 +2469,7 @@ fn apply_comp_to(_: &str, p: &Value) -> Option { fn play_from(d: &Descriptor) -> Mapped { match get_ref(d, "null").map(target) { - Some(Target::Name(n)) => step("actions.play", json!({"action": n})), + Some(Target::Name(n)) => step("actions.play", json!({"action": clip_name(&n)})), _ => Mapped::Unsupported { harmless: false }, } } @@ -3037,13 +3177,14 @@ macro_rules! adjustment_event { const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")]; -/// Whether a Select and Mask step carries Refine Edge Brush strokes, which no descriptor holds. +/// Whether a Select and Mask step carries Refine Edge Brush strokes or the workspace's tool +/// edits (Quick Selection, Brush, Lasso…), which no descriptor holds. fn has_refine_strokes(p: &Value) -> bool { - p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) + p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) || crate::select_mask_edits::has_edits(p) } -/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush, so a -/// step with `refineStrokes` can't be exported faithfully. One with `sampleAllLayers` is not +/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush or the +/// workspace's tools, so a step with `refineStrokes` or `edits` can't be exported faithfully. One with `sampleAllLayers` is not /// exported here either: it exports as `smartBrushWorkspace`. fn refine_edge_to(_: &str, p: &Value) -> Option { if !pixel_target(p) || p.get("sampleAllLayers").is_some() || has_refine_strokes(p) { @@ -3356,7 +3497,7 @@ const EVENTS: &[Event] = &[ adjustment_event!("Exps", "Exps", "exposure", "Exposure"), // Photoshop runs Photo Filter by name only: `PhtF` is the class, not an event (#8301). adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"), - adjustment_event!("", "BlnW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"), + adjustment_event!("", "BanW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"), adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), adjustment_event!("GdMp", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"), adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), @@ -3657,7 +3798,7 @@ const EVENTS: &[Event] = &[ Event { code: "Plc ", name: "placeEvent", label: "Place", ids: &["file.placeEmbedded", "file.placeLinked"], from: place_from, to: place_to }, Event { code: "Opn ", name: "open", label: "Open", ids: &["file.openAs"], from: open_from, to: open_to }, Event { code: "", name: "conditional", label: "If", ids: &["actions.conditional"], from: conditional_from, to: conditional_to }, - ev!("save", "save", "Save", "file.save", &[]), + Event { code: "save", name: "save", label: "Save", ids: &["file.save", "file.saveAs", "file.saveACopy"], from: save_from, to: save_to }, ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, Event { code: "", name: "applyComp", label: "Apply Layer Comp", ids: &["layerComp.apply"], from: apply_comp_from, to: apply_comp_to }, @@ -3711,7 +3852,11 @@ pub struct Imported { fn photoshop_step(item: &AtnItem) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); - (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.label(), "descriptor": descriptor})) + let event_kind = match &item.event { + AtnEvent::Code(_) => "code", + AtnEvent::Name(_) => "name", + }; + (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "eventKind": event_kind, "name": item.label(), "descriptor": descriptor})) } /// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings @@ -3733,10 +3878,18 @@ fn normalise(v: &mut Value) { } } +/// Longest action or set name kept (Photoshop's own limit is 255). A file's names are lengths it +/// chooses, and each is copied into every action and every unsupported-step diagnostic. +const MAX_NAME_CHARS: usize = 1024; + +fn clip_name(s: &str) -> String { + s.chars().take(MAX_NAME_CHARS).collect() +} + fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) -> Action { - let name = a.name.to_string_lossy(); + let name = clip_name(&a.name.to_string_lossy()); let mut action = Action::new(name.clone(), Vec::new()); - action.set = Some(set.to_string()); + action.set = Some(clip_name(set)); for (i, item) in a.items.iter().enumerate() { let mapped = match (event_for(&item.event), &item.descriptor) { (Some(e), Some(d)) => (e.from)(d), @@ -3770,7 +3923,7 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) pub fn import(bytes: &[u8]) -> std::result::Result { let file = atn::parse(bytes).map_err(|e| e.to_string())?; let set = file.name.to_string_lossy(); - let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { set }; + let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { clip_name(&set) }; let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; for a in &file.actions { let action = convert_action(a, &set, &mut out.unsupported); @@ -3795,9 +3948,16 @@ pub struct Skipped { fn export_item(id: &str, params: &Value, set: &str) -> Option { if id == PHOTOSHOP_STEP { let event = jstr(params, "event")?; - let event = match event.as_bytes() { - [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), - _ => AtnEvent::name(event), + let event = match jstr(params, "eventKind") { + Some("code") => AtnEvent::Code(event.as_bytes().try_into().ok()?), + Some("name") => AtnEvent::name(event), + Some(_) => return None, + // Older PhotoCraft action data did not retain the event kind. Keep its previous + // best-effort export behavior for compatibility. + None => match event.as_bytes() { + [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), + _ => AtnEvent::name(event), + }, }; let descriptor = match jstr(params, "descriptor") { Some(b64) => Some(Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(b64)?).ok()?), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index d83d69e624..2b967f8c9d 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -3,6 +3,59 @@ use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::json; +#[test] +fn blend_modes_accept_photoshop_string_ids_and_export_canonical_char_ids() { + for (string_id, char_id, label) in [ + ("normal", "Nrml", "Normal"), + ("dissolve", "Dslv", "Dissolve"), + ("darken", "Drkn", "Darken"), + ("multiply", "Mltp", "Multiply"), + ("colorBurn", "CBrn", "Color Burn"), + ("lighten", "Lghn", "Lighten"), + ("screen", "Scrn", "Screen"), + ("colorDodge", "CDdg", "Color Dodge"), + ("overlay", "Ovrl", "Overlay"), + ("softLight", "SftL", "Soft Light"), + ("hardLight", "HrdL", "Hard Light"), + ("difference", "Dfrn", "Difference"), + ("exclusion", "Xclu", "Exclusion"), + ("hue", "H", "Hue"), + ("saturation", "Strt", "Saturation"), + ("color", "Clr", "Color"), + ("luminosity", "Lmns", "Luminosity"), + ("passThrough", "PsTh", "Pass Through"), + ] { + assert_eq!(blend_label(string_id), Some(label), "{string_id}"); + assert_eq!(blend_ps(label), Some(char_id), "{label} remains serialized canonically"); + } +} + +/// #8454: a Set and a Make layer step whose blend mode is stored as a Photoshop stringID import +/// with that mode (a Set was unsupported, a Make silently fell back to Normal). +#[test] +fn layer_steps_with_string_id_blend_modes_import_with_their_mode() { + let set = desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(desc("Lyr ").with("Md ", enm("BlnM", "softLight")))); + let make = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(desc("Lyr ").with("Md ", enm("BlnM", "luminosity")))); + let file = AtnFile { + name: UnicodeString::new_nul("Blend"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![item("setd", "Set", Some(set)), item("Mk ", "Make", Some(make))], + ..Default::default() + }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + assert!(imp.unsupported.is_empty(), "{:?}", imp.unsupported.iter().map(|u| &u.event).collect::>()); + let steps = &imp.actions[0].steps; + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].0, "layer.setProps"); + assert_eq!(steps[0].1["blend"], json!("Soft Light")); + assert_eq!(steps[1].0, "layer.new.layer"); + assert_eq!(steps[1].1["mode"], json!("Luminosity")); +} + fn item(event: &str, label: &str, d: Option) -> AtnItem { let event = match event.as_bytes() { [a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]), @@ -262,6 +315,25 @@ fn load_renames_each_play_target_once() { assert_eq!(s.actions.list[1].steps[0].1, json!({"action": "A"}), "B still plays the A it had"); } +/// A name over the 1,024-character limit is clipped for the action and for the steps that call it, +/// so a rename still finds its Play target. +#[test] +fn load_renames_play_targets_longer_than_the_name_limit() { + let mut s = Session::new(); + let long = "x".repeat(2000); + let clipped = "x".repeat(1024); + let mut mine = Action::new(clipped.clone(), vec![]); + mine.set = Some("Mine".into()); + s.actions.list.push(mine); + let a = Action::new(long.clone(), vec![]); + let caller = Action::new("Caller", vec![("actions.play".into(), json!({"action": long}))]); + let (bytes, _) = export("File", &[&a, &caller], &[], false).unwrap(); + s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + let renamed = format!("{clipped} 2"); + let caller = s.actions.list.iter().find(|a| a.name == "Caller").unwrap(); + assert_eq!(caller.steps[0].1["action"], renamed.as_str()); +} + #[test] fn load_many_duplicate_names_get_sequential_suffixes() { // Several actions share one base name. Deduplication must stay O(n) overall and produce @@ -552,6 +624,8 @@ fn every_mapped_command_exports_and_imports_back() { ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), ("file.placeLinked", json!({"path": "/tmp/logo.png"})), ("file.openAs", json!({"path": "/tmp/a.psd"})), + ("file.saveAs", json!({"path": "/tmp/out/a.png"})), + ("file.saveACopy", json!({"path": "/tmp/out/a.tif"})), ("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})), ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), ("tools.setColors", json!({"foreground": "#102030"})), @@ -886,6 +960,34 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } +/// #8482: four-byte string events must not become `long` event codes when retained and saved. +#[test] +fn save_preserves_the_kind_of_unsupported_atn_events() { + let source = AtnFile { + name: UnicodeString::new_nul("S"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![ + AtnItem { event: AtnEvent::Name(b"make".to_vec()), dictionary: b"Make".to_vec(), ..Default::default() }, + AtnItem { event: AtnEvent::Code(*b"Qwer"), dictionary: b"Unknown code".to_vec(), ..Default::default() }, + ], + ..Default::default() + }], + warnings: vec![], + }; + let mut session = Session::new(); + session.execute("actions.load", json!({"data": b64(&atn::write(&source).unwrap())})).unwrap(); + let saved = session.execute("actions.save", json!({"set": "S"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let round_trip = atn::parse(&bytes).unwrap(); + + assert_eq!( + round_trip.actions[0].items.iter().map(|item| item.event.clone()).collect::>(), + [AtnEvent::Name(b"make".to_vec()), AtnEvent::Code(*b"Qwer")] + ); +} + /// #6745: the events of a Photoshop 2026 retouching set (ID-photo background swap, whitening, /// highlight cleanup, high-pass sharpening), in the stringID form Photoshop wrote them. #[test] @@ -1777,6 +1879,42 @@ fn photo_filter_saves_under_the_name_photoshop_runs() { } } +/// A Black & White adjustment layer saves with Photoshop's `BanW` class (#8481): measured in +/// Photoshop, a Make whose type is `BlnW` is refused. Photoshop's own `BanW` Make loads, and so +/// does the `BlnW` one PhotoCraft wrote before. +#[test] +fn black_white_adjustment_layer_saves_as_photoshops_class_and_old_class_still_imports() { + let id = "layer.newAdjustmentLayer.blackWhite"; + let params = json!({"reds": 17, "yellows": 83, "greens": -12, "cyans": 107, "blues": 51, "magentas": 133, "tint": true, "tintColor": "#c08040"}); + let mut s = Session::new(); + s.actions.list.push(Action::new("BW", vec![(id.into(), params.clone())])); + let r = s.execute("actions.save", json!({"action": "BW"})).unwrap(); + assert_eq!(r["skipped"], json!([])); + let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&saved).unwrap(); + let make = file.actions[0].items[0].descriptor.as_ref().unwrap(); + let kind = get_obj(make, "Usng").and_then(|u| get_obj(u, "Type")).unwrap(); + assert_eq!(trimmed(&kind.class_id), "BanW"); + let mut t = Session::new(); + let r = t.execute("actions.load", json!({"data": b64(&saved)})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + assert_eq!(t.actions.list[0].steps, vec![(id.to_string(), params)]); + + // Photoshop's minimal Make (no settings) and an old PhotoCraft Make with a setting both load, + // the setting kept; near misses stay Photoshop steps. + let make = |class: &str| { + let kind = if class == "BlnW" { desc(class).with("Rd ", long(17.0)) } else { desc(class) }; + let using = desc("AdjL").with("Type", D::Descriptor(kind)); + item("Mk ", "Make", Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using)))) + }; + let data = set_of("S", vec![("A", ["BanW", "BlnW", "BANW", "BlnWx"].into_iter().map(make).collect())]); + let mut t = Session::new(); + let r = t.execute("actions.load", json!({"data": data})).unwrap(); + let steps: Vec<&str> = t.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(steps, [id, id, PHOTOSHOP_STEP, PHOTOSHOP_STEP], "{r}"); + assert_eq!(t.actions.list[0].steps[1].1["reds"], 17, "{r}"); +} + /// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in /// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. #[test] @@ -2053,6 +2191,77 @@ fn options_the_descriptors_cannot_carry_refuse_strict_export() { } } +/// #8697: Photoshop's other transform interpolation modes survive import: its bicubic variants +/// are kept (they render as Bicubic and export back unchanged), Automatic becomes Bicubic +/// Automatic and Preserve Details Bicubic; before, they fell back to the command's default. +#[test] +fn photoshop_transform_interpolation_modes_survive_import() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + for id in ["select.transformSelection", "edit.transform"] { + for (mode, imported) in [ + ("bicubicSharper", "bicubicSharper"), + ("bicubicSmoother", "bicubicSmoother"), + ("bicubicAutomatic", "bicubicAutomatic"), + ("automatic", "bicubicAutomatic"), + ("preserveDetails", "bicubic"), + ] { + let (bytes, _) = export("Audit8", &[&one(id, json!({"scaleX": 140, "interpolation": mode}))], &[], false).unwrap(); + let steps = import(&bytes).unwrap().actions[0].steps.clone(); + let got = steps.first().and_then(|(_, p)| p.get("interpolation")).and_then(Value::as_str).map(str::to_owned); + assert_eq!(got.as_deref(), Some(imported), "{id} {mode}: {steps:?}"); + } + } +} + +/// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written +/// (their descriptors carry every setting, filling absent ones with defaults), so strict export +/// refuses it instead of replaying an edit that also resets the rest. Complete ones still export. +#[test] +fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() { + let one = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.setAdjustment".into(), p)], ..Default::default() }; + for p in [ + json!({"kind": "colorBalance", "shadows": [32, -24, 16]}), + json!({"kind": "colorBalance", "preserveLuminosity": true}), + json!({"kind": "hueSaturation", "hue": 20}), + json!({"kind": "hueSaturation", "reds": {"hue": 10}}), + ] { + let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err(); + assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}"); + let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}"); + } + let full = json!({"kind": "colorBalance", "shadows": [32, -24, 16], "midtones": [-17, 27, -31], "highlights": [11, -19, 23], "preserveLuminosity": false}); + let (bytes, _) = export("Audit8", &[&one(full)], &[], false).unwrap(); + let steps = import(&bytes).unwrap().actions[0].steps.clone(); + let (id, p) = steps.first().unwrap(); + assert_eq!(id, "layer.setAdjustment"); + assert_eq!(p.get("midtones"), Some(&json!([-17, 27, -31])), "{p}"); + assert_eq!(p.get("preserveLuminosity"), Some(&json!(false)), "{p}"); + let hue = json!({"kind": "hueSaturation", "colorize": false, "hue": 20, "saturation": -5, "lightness": 0}); + assert!(export("Audit8", &[&one(hue)], &[], false).is_ok()); + // A colour range written without all of its own settings would reset the rest too. + let range = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10}}); + assert!(export("Audit8", &[&one(range)], &[], false).is_err()); + let range = + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5}}); + assert!(export("Audit8", &[&one(range)], &[], false).is_ok()); + let bounded = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5, "range": [315, 345, 15, 45]}}); + assert!(export("Audit8", &[&one(bounded)], &[], false).is_ok()); + // Values of the wrong shape would be written as 0: refused too. + for bad in [ + json!({"kind": "colorBalance", "shadows": "invalid", "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": true}), + json!({"kind": "colorBalance", "shadows": [1, 2], "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": true}), + json!({"kind": "colorBalance", "shadows": [0, 0, 0], "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": "yes"}), + json!({"kind": "hueSaturation", "colorize": false, "hue": "20", "saturation": 0, "lightness": 0}), + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": 5}), + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [0, 20, 40]}}), + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": "invalid"}}), + json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [315, 345.5, 15, 45]}}), + ] { + assert!(export("Audit8", &[&one(bad.clone())], &[], false).is_err(), "{bad}"); + } +} + /// Photoshop's Close has no document target: a Close of a specific document must not be /// exported as a plain Close, which would close the active document on playback. #[test] @@ -2316,6 +2525,75 @@ fn function_keys_keep_shift_and_command() { assert_eq!(super::function_key_name(7, false, false), "F7"); } +/// The Apply Image dialog's flat source fields keep their meaning through a strict `.atn` export +/// (#7197): they used to come back as the merged composite. +#[test] +fn apply_image_flat_source_fields_survive_a_strict_export() { + let flat = json!({"sourceLayer": "Pixels", "sourceChannel": "green", "sourceInvert": true, "blending": "normal", "opacity": 75}); + let item = export_item("image.applyImage", &flat, "S").expect("the flat form exports"); + let event = event_for(&item.event).expect("a known event"); + let Mapped::Steps(steps) = (event.from)(item.descriptor.as_ref().unwrap()) else { panic!("does not import back") }; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].1["source"], json!({"layer": "Pixels", "channel": "green", "invert": true})); + // The nested form is unchanged, and the "merged" layer of a flat form stays merged. + let merged = json!({"sourceLayer": "merged", "sourceChannel": "red"}); + let item = export_item("image.applyImage", &merged, "S").expect("exports"); + let Mapped::Steps(steps) = (event.from)(item.descriptor.as_ref().unwrap()) else { panic!("does not import back") }; + assert_eq!(steps[0].1["source"], json!({"layer": "merged", "channel": "red"})); + // A source in another document cannot be written: the step is skipped, not rewritten. + assert!(export_item("image.applyImage", &json!({"source": {"document": "Other", "channel": "red"}}), "S").is_none()); + assert!(export_item("image.applyImage", &json!({"sourceDocument": "Other"}), "S").is_none()); +} + +/// What the Apply Image dialog records survives a strict `.atn` export and comes back: its source +/// channel is a Channels-panel name ("RGB", "Red", "Alpha 1"), not a lowercase key. +#[test] +fn apply_image_dialog_channel_names_survive_a_strict_export() { + let dialog = |channel: &str| json!({"sourceLayer": "merged", "sourceChannel": channel, "sourceInvert": false, "blending": "multiply", "opacity": 100.0, "scale": 1.0, "offset": 0, "preserveTransparency": false, "maskChannel": "none"}); + for (channel, back) in [ + ("RGB", "composite"), + ("CMYK", "composite"), + ("Lab", "composite"), + ("Red", "red"), + ("Gray", "gray"), + ("Alpha 1", "Alpha 1"), + ("transparency", "transparency"), + ] { + let a = Action::new("A", vec![("image.applyImage".into(), dialog(channel))]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap_or_else(|e| panic!("{channel}: {e}")); + assert!(skipped.is_empty(), "{channel}"); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items[0].event.id(), "AppI"); + let back_file = import(&bytes).unwrap(); + assert!(back_file.unsupported.is_empty(), "{channel}"); + let p = &back_file.actions[0].steps[0].1; + assert_eq!(p["source"], json!({"layer": "merged", "channel": back, "invert": false}), "{channel}"); + } + // A named channel is written as Photoshop records it: a `Chnl` name reference. + let item = export_item("image.applyImage", &dialog("Alpha 1"), "S").unwrap(); + let with = get_obj(item.descriptor.as_ref().unwrap(), "With").unwrap(); + let r = get_ref(with, "T ").unwrap(); + assert!(matches!(&r[0], ReferenceItem::Name { class, name } if class.class_id.is("Chnl") && name.to_string_lossy() == "Alpha 1")); +} + +/// A numeric layer or channel selector has no name to record: the step is refused, not exported as +/// the merged composite. +#[test] +fn apply_image_numeric_source_selectors_are_refused() { + for p in [ + json!({"sourceLayer": 3}), + json!({"sourceChannel": 2}), + json!({"source": {"channel": 2}}), + json!({"source": {"layer": 7, "channel": "red"}}), + json!({"source": "red"}), + json!({"sourceChannel": "quickMask"}), + ] { + assert!(export_item("image.applyImage", &p, "S").is_none(), "{p}"); + } + let a = Action::new("A", vec![("image.applyImage".into(), json!({"sourceLayer": 3}))]); + assert!(export("S", &[&a], &[], false).is_err()); +} + /// A param a PhotoCraft step leaves out exports as PhotoCraft's default wherever Photoshop plays /// the missing key differently (checked against Photoshop): Canvas Size anchors top left, /// Apply Image blends Normal, Color Balance turns preserve luminosity off, and a Paint Bucket @@ -2334,6 +2612,32 @@ fn defaults_photoshop_plays_differently_are_written() { assert_eq!(get_list(&balance, "ShdL").map(<[D]>::len), Some(3)); } +/// Photoshop records the composite of a Levels or Curves entry as `Chnl.Cmps` and silently skips +/// a Set that names it `Ordn.Trgt` (#8543). A colour channel keeps its own code. +#[test] +fn levels_and_curves_name_the_composite_channel_as_photoshop_records_it() { + let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export")); + let channels = |d: &Descriptor| -> Vec<(String, String)> { + get_list(d, "Adjs") + .unwrap_or_default() + .iter() + .filter_map(|e| match e { + D::Descriptor(e) => match get_ref(e, "Chnl")?.first()? { + ReferenceItem::Enumerated { type_id, value, .. } => Some((trimmed(type_id), trimmed(value))), + _ => None, + }, + _ => None, + }) + .collect() + }; + let composite = ("Chnl".to_string(), "Cmps".to_string()); + let red = ("Chnl".to_string(), "Rd".to_string()); + let levels = exported("image.adjustments.levels", json!({"inBlack": 31, "red": {"inBlack": 2}})); + assert_eq!(channels(&levels), vec![composite.clone(), red.clone()]); + let curves = exported("image.adjustments.curves", json!({"points": [[0, 0], [128, 140], [255, 255]], "red": [[0, 10], [255, 245]]})); + assert_eq!(channels(&curves), vec![composite, red]); +} + /// Photoshop sets one colour per step: a step that sets both doesn't export rather than drop one. #[test] fn set_colors_exports_only_a_single_colour() { @@ -2466,6 +2770,23 @@ fn refine_edge_with_brush_strokes_is_not_exported_without_them() { } } +/// Nor does it hold the workspace's tool edits (Quick Selection, Brush, Lasso…), with or without +/// Sample All Layers, so a step with them is reported instead of exported without them. +#[test] +fn refine_edge_with_workspace_edits_is_not_exported_without_them() { + let edit = json!({"tool": "quick", "points": [[5, 4]], "size": 4}); + for params in [json!({"radius": 1, "edits": [edit]}), json!({"radius": 1, "sampleAllLayers": true, "edits": [{"tool": "invert"}]})] { + let edited = Action { name: "Probe".into(), steps: vec![("select.refineEdge".into(), params.clone())], ..Default::default() }; + assert!(export("Audit", &[&edited], &[], false).is_err(), "{params}"); + let (_, skipped) = export("Audit", &[&edited], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]); + } + for params in [json!({"radius": 1, "edits": []}), json!({"radius": 1, "edits": null})] { + let plain = Action { name: "Plain".into(), steps: vec![("select.refineEdge".into(), params)], ..Default::default() }; + assert!(export("Audit", &[&plain], &[], false).is_ok()); + } +} + #[test] fn image_size_resolution_only_does_not_gain_keep_print_size() { // The .atn cannot tell resolution-only from keepPrintSize:true, so the former must not @@ -2685,3 +3006,116 @@ fn hue_saturation_uses_photoshops_hue_key() { normalise(&mut p); assert_eq!(p["hue"], json!(30), "{p}"); } + +fn one_action(items: Vec) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("Saves"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + } +} + +fn save_as(format: &str, path: &str) -> Descriptor { + desc("null").with("As ", D::Descriptor(desc(format))).with("In ", path_value(path)).with("DocI", long(53.0)) +} + +#[test] +fn save_as_to_a_file_imports_with_its_destination() { + let file = one_action(vec![ + item("save", "Save", Some(save_as("PNGF", "/out/result.png"))), + item("save", "Save", Some(save_as("Google WebPShop", "C:\\out\\result.webp").with("Cpy ", D::Boolean(true)))), + item("save", "Save", Some(save_as("JPEG", "/out/result.jpeg"))), + item("save", "Save", Some(save_as("WebPFormat", "/out/result.webp"))), + item("save", "Save", None), + item("save", "Save", Some(desc("null").with("DocI", long(53.0)))), + ]); + let imp = import(&atn::write(&file).unwrap()).unwrap(); + assert_eq!( + imp.actions[0].steps, + [ + ("file.saveAs".to_string(), json!({"path": "/out/result.png"})), + ("file.saveACopy".to_string(), json!({"path": "C:\\out\\result.webp"})), + ("file.saveAs".to_string(), json!({"path": "/out/result.jpeg"})), + ("file.saveAs".to_string(), json!({"path": "/out/result.webp"})), + ("file.save".to_string(), json!({})), + ("file.save".to_string(), json!({})), + ] + ); + assert!(imp.unsupported.is_empty()); +} + +#[test] +fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file() { + // Before #7102 each of these imported as a plain File › Save, which overwrote the file the + // action was played on. + let file = one_action(vec![ + item("save", "Save", Some(save_as("PNGF", "/out/folder"))), + item("save", "Save", Some(save_as("PNGF", "/out/v1.png").with("DIDr", D::Boolean(true)))), + item("save", "Save", Some(save_as("PNGF", "/out/result.webp"))), + item("save", "Save", Some(save_as("GIFf", "/out/result.gif"))), + item("save", "Save", Some(desc("null").with("As ", D::Descriptor(desc("PNGF"))))), + // A format that isn't a descriptor, with and without a destination. + item("save", "Save", Some(desc("null").with("As ", long(1.0)))), + item("save", "Save", Some(desc("null").with("As ", long(1.0)).with("In ", path_value("/out/result.png")))), + // A destination with no format at all. + item("save", "Save", Some(desc("null").with("In ", path_value("/out/result.png")))), + ]); + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, [PHOTOSHOP_STEP; 8]); + assert!(imp.actions[0].disabled.is_empty(), "kept saves stay on, so playback stops on them"); + // The kept steps are written back byte for byte. + let a = &imp.actions[0]; + let (bytes, skipped) = export("Saves", &[a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let back = atn::parse(&bytes).unwrap(); + for (out, orig) in back.actions[0].items.iter().zip(&file.actions[0].items) { + assert_eq!(out.descriptor, orig.descriptor); + } +} + +#[test] +fn saves_round_trip_through_photoshop_events() { + let steps = vec![ + ("file.saveAs".to_string(), json!({"path": "/out/result.psd"})), + ("file.saveACopy".to_string(), json!({"path": "/out/copy.webp"})), + ("file.save".to_string(), json!({})), + ]; + let a = Action::new("A", steps.clone()); + let (bytes, skipped) = export("Saves", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let items = &file.actions[0].items; + assert!(items.iter().all(|i| i.event.id() == "save")); + let first = items[0].descriptor.as_ref().unwrap(); + assert_eq!(get_obj(first, "As ").map(|f| trimmed(&f.class_id)).as_deref(), Some("Pht3")); + assert_eq!(first.get("In ").and_then(path_text).as_deref(), Some("/out/result.psd")); + assert_eq!(get_bool(items[1].descriptor.as_ref().unwrap(), "Cpy "), Some(true)); + // Photoshop's own WebP class, not the plug-in's: Photoshop 2026 rejects `Google WebPShop`. + assert_eq!(get_obj(items[1].descriptor.as_ref().unwrap(), "As ").map(|f| trimmed(&f.class_id)).as_deref(), Some("WebPFormat")); + assert_eq!(import(&bytes).unwrap().actions[0].steps, steps); + + // A Save As PhotoCraft can't express in Photoshop's terms is skipped, never exported as a + // plain Save. + let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"path": "/out/result.xcf"}))]); + assert!(export("Saves", &[&odd], &[], false).is_err()); +} + +#[test] +fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { + let long = "N".repeat(5000); + let unknown = |n: usize| (0..n).map(|_| item("zzzz", "Mystery", None)).collect::>(); + let file = AtnFile { + name: UnicodeString::new_nul(&long), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul(&long), items: unknown(50), ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + assert_eq!(imp.set.chars().count(), MAX_NAME_CHARS); + assert_eq!(imp.actions[0].name.chars().count(), MAX_NAME_CHARS); + assert_eq!(imp.actions[0].set.as_deref().map(|s| s.chars().count()), Some(MAX_NAME_CHARS)); + assert_eq!(imp.unsupported.len(), 50); + assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); +} diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 2256a76cda..42fde9e9bf 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -119,12 +119,27 @@ fn check_known(steps: &[(String, Value)], cmd: &str) -> Result<()> { } } +/// Scripts nested in scripts, and steps one top-level script (with everything it calls) may run: +/// a script can name others, so a graph that calls each of the previous ones twice is exponential. +const MAX_SCRIPT_DEPTH: u8 = 16; +pub(crate) const MAX_SCRIPT_STEPS: u64 = crate::actions_cmds::MAX_PLAYBACK_STEPS; + +/// A script run from a script reports its own failure inside an `Ok`; it stops the caller too. +fn nested_script_failure(id: &str, r: &Value) -> Option { + (id == "file.scripts.browse" && r.get("ok") == Some(&Value::Bool(false))).then(|| "a script it runs failed".to_string()) +} + fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec { let mut results = Vec::new(); for (id, params) in steps { + s.actions.script_steps = s.actions.script_steps.saturating_add(1); + if s.actions.script_steps > MAX_SCRIPT_STEPS { + results.push(json!({"command": id, "error": format!("the script ran more than {MAX_SCRIPT_STEPS} steps in all (scripts calling scripts)")})); + break; + } match s.execute(id, params.clone()) { // A called action reports a failed step inside an `Ok`; it stops the script too. - Ok(r) => match crate::actions_cmds::nested_failure(id, &r) { + Ok(r) => match crate::actions_cmds::nested_failure(id, &r).or_else(|| nested_script_failure(id, &r)) { Some(error) => { results.push(json!({"command": id, "result": r, "error": error})); break; @@ -156,7 +171,15 @@ fn browse(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(cmd, "give \"path\" (a script file), \"script\" (its text) or \"steps\"")), }; check_known(&steps, cmd)?; + if s.actions.script_depth >= MAX_SCRIPT_DEPTH { + return Err(bad(cmd, format!("scripts are nested more than {MAX_SCRIPT_DEPTH} deep"))); + } + if s.actions.script_depth == 0 { + s.actions.script_steps = 0; + } + s.actions.script_depth += 1; let results = run_steps(s, &steps); + s.actions.script_depth -= 1; let ok = results.iter().all(|r| r.get("error").is_none()); Ok(json!({"steps": steps.len(), "ok": ok, "results": results})) } @@ -358,7 +381,9 @@ fn write_shim(droplet: &str) -> Result { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)); + // The shim only has to run as the user who created it; 0o755 would also let every other + // local account read and execute it. + let _ = std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o700)); } Ok(shim) } diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 8bea15c69a..3402496331 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -484,3 +484,46 @@ fn load_files_into_stack_aligns_before_the_smart_object() { s.execute("file.scripts.loadFilesIntoStack", json!({"paths": [files[0].clone()], "align": true})).unwrap(); assert_eq!(s.active().unwrap().doc.layers.len(), 1); } + +/// A script file of `lines` (one `file.scripts.browse` call per entry) in `dir`. +fn script_calling(dir: &str, name: &str, callees: &[String]) -> String { + let path = format!("{dir}/{name}.txt"); + let body: String = callees.iter().map(|c| format!("file.scripts.browse {}\n", json!({"path": c}))).collect(); + std::fs::write(&path, body).unwrap(); + path +} + +#[test] +fn scripts_calling_scripts_share_one_step_budget() { + let dir = tmp("fanout"); + // Eight levels, each calling the one below six times: 6^8 (1.7 million) calls of the leaf. + let mut below = script_calling(&dir, "leaf", &[]); + std::fs::write(&below, "select.deselect {}\n").unwrap(); + for level in 0..8 { + below = script_calling(&dir, &format!("level{level}"), &vec![below; 6]); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let started = std::time::Instant::now(); + let r = s.execute("file.scripts.browse", json!({"path": below})).unwrap(); + assert_eq!(r["ok"], false, "the budget stops the run: {r}"); + assert!(started.elapsed().as_secs() < 60, "{:?}", started.elapsed()); + // The budget restarts with the next top-level script. + s.execute("file.scripts.browse", json!({"script": "select.deselect {}"})).unwrap(); +} + +#[test] +fn scripts_nested_deeper_than_the_limit_fail() { + let dir = tmp("deep"); + let mut below = script_calling(&dir, "leaf", &[]); + std::fs::write(&below, "select.deselect {}\n").unwrap(); + for level in 0..20 { + below = script_calling(&dir, &format!("level{level}"), &[below]); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let r = s.execute("file.scripts.browse", json!({"path": below})).unwrap(); + assert_eq!(r["ok"], false, "{r}"); + assert!(r.to_string().contains("nested more than"), "{r}"); + assert_eq!(s.actions.script_depth, 0, "the depth is released"); +} diff --git a/crates/engine/src/build_info.rs b/crates/engine/src/build_info.rs index b7ed60e2fb..f110ada56e 100644 --- a/crates/engine/src/build_info.rs +++ b/crates/engine/src/build_info.rs @@ -33,6 +33,12 @@ pub fn long_version() -> String { format_long(VERSION, short_sha(), build_date()) } +/// [`long_version`] worked out once, for a `&'static str` such as clap's `version`. +pub fn long_version_static() -> &'static str { + static LONG: std::sync::OnceLock = std::sync::OnceLock::new(); + LONG.get_or_init(long_version) +} + fn format_long(version: &str, sha: Option<&str>, date: Option<&str>) -> String { match (sha, date) { (Some(s), Some(d)) => format!("{version} ({s}, {d})"), @@ -58,6 +64,7 @@ mod tests { fn version_is_the_workspace_version() { assert!(!VERSION.is_empty()); assert!(long_version().starts_with(VERSION)); + assert_eq!(long_version_static(), long_version()); if let Some(s) = short_sha() { assert!(s.len() <= 9); } diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 3233135132..77ac5a2d06 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -100,6 +100,7 @@ pub(crate) fn set_resolution(doc: &mut Document, dpi: f32) { path.knots.iter_mut().for_each(|kn| *kn = kn.transform(&grow)); *start = grown(*start, k); } + photocraft_doc::text::TextShape::Area { path } => *path = path.transform(&grow), photocraft_doc::text::TextShape::Point => {} } retag_text(dpi, t); diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 0790537026..bda9eae0b0 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -569,7 +569,7 @@ impl Blending { /// A source spec from `p[key]`, or from flat `Document` / `Layer` / /// `Channel` / `Invert` keys (what the generic dialog form sends). -fn source_spec(p: &Value, key: &str, prefix: &str) -> Option { +pub(crate) fn source_spec(p: &Value, key: &str, prefix: &str) -> Option { if let Some(v) = p.get(key) { return Some(v.clone()); } diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 4f3435cc7a..9e9c0aafd3 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -590,7 +590,8 @@ pub fn resolve_profile(spec: &str, doc: Option<&Document>, mode: Option = Builtin::ALL.iter().map(|b| b.id()).collect(); @@ -715,7 +716,12 @@ fn convert_layer_colors(l: &mut Layer, cc: &mut dyn FnMut(&mut Color)) { Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint_colors(&mut g.paint, cc), Effect::Stroke(s) => paint_colors(&mut s.paint, cc), Effect::ColorOverlay { color, .. } => cc(color), - Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)), + Effect::GradientOverlay { gradient, interpolation, .. } => { + gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)); + if let Some(source) = interpolation { + source.stops.iter_mut().for_each(|s| cc(&mut s.1)); + } + } Effect::Satin(s) => cc(&mut s.color), Effect::BevelEmboss(b) => { cc(&mut b.highlight_color); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2b65788fcc..767a9791b0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -80,7 +80,7 @@ pub(crate) fn paint_surface<'a>(doc: &'a mut Document, id: LayerId, p: &Value) - } } -fn check_all_unlocked(doc: &Document, id: LayerId) -> Result<()> { +pub(crate) fn check_all_unlocked(doc: &Document, id: LayerId) -> Result<()> { let locks = doc.effective_locks(id); let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; if locks.all { @@ -282,6 +282,10 @@ pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> } pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { let s = s.trim_start_matches('#'); + // `from_str_radix` accepts a leading '+', so "+f" would read as 0x0f. + if !s.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0); match s.len() { 6 => Some([b(0)?, b(2)?, b(4)?, 1.0]), @@ -461,10 +465,11 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) // The targeted layer mask (#780): ⌘I inverts it, as in Photoshop. let id = layer_param(s, &Value::Null)?; return s.edit(label, |doc, _| { + check_all_unlocked(doc, id)?; let sel = doc.selection.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let mask = l.mask.as_mut().ok_or_else(|| EngineError::Other("layer has no mask".into()))?; - pixels::adjust_mask(&mut mask.surface, &adj, sel.as_ref()); + pixels::adjust_mask(&mut mask.surface, &adj, sel.as_ref()).map_err(EngineError::Other)?; Ok(Value::Null) }); } @@ -474,7 +479,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) return s.edit(label, |doc, _| { let sel = doc.selection.clone(); if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? { - pixels::adjust_mask(surf, &adj, sel.as_ref()); + pixels::adjust_mask(surf, &adj, sel.as_ref()).map_err(EngineError::Other)?; } Ok(Value::Null) }); @@ -484,7 +489,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) let sel = doc.selection.clone(); let mode = doc.mode; let surf = paint_surface(doc, id, &Value::Null)?; - pixels::adjust_surface(surf, &adj, sel.as_ref(), mode); + pixels::adjust_surface(surf, &adj, sel.as_ref(), mode).map_err(EngineError::Other)?; Ok(()) })?; Ok(Value::Null) @@ -1135,7 +1140,7 @@ fn build() -> Vec { "Reorder Layer", [], None, - r##"{"layer":id?| "layers":[id,…]?, "target":id,"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row)"##, + r##"{"layer":id?| "layers":[id,…]?, "target":id|name,"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; a recorded action keeps the target by name)"##, has_layer, crate::layer_multi_cmds::move_to ), @@ -1508,6 +1513,7 @@ fn build() -> Vec { v.extend(crate::brush_preset_cmds::specs()); v.extend(crate::eraser_cmds::specs()); v.extend(crate::preset_import_cmds::specs()); + v.extend(crate::style_asl_cmds::specs()); v.extend(crate::atn_cmds::specs()); v.extend(crate::actions_edit_cmds::specs()); v.extend(crate::swatch_cmds::specs()); @@ -1524,6 +1530,7 @@ fn build() -> Vec { v.extend(crate::file_cmds::specs()); v.extend(crate::exr_cmds::specs()); v.extend(crate::type_extra_cmds::specs()); + v.extend(crate::type_area_cmds::specs()); v.extend(crate::type_styles_cmds::specs()); v.extend(crate::type_spell_cmds::specs()); v.extend(crate::type_caret_cmds::specs()); @@ -1531,6 +1538,7 @@ fn build() -> Vec { v.extend(crate::layer_multi_cmds::specs()); v.extend(crate::layer_copy_cmds::specs()); v.extend(crate::prefs::specs()); + v.extend(crate::updates_cmds::specs()); v.extend(crate::edit_menu_cmds::specs()); v.extend(crate::fill_key_cmds::specs()); v.extend(crate::sample_cmds::specs()); @@ -1691,6 +1699,7 @@ fn set_mask_with(s: &mut Session, p: &Value, label: &str, mask: Option = walkdir(&pkg).into_iter().filter(|f| std::fs::read(f).is_ok_and(|b| b == TEXT)).collect(); + assert!(copies.is_empty(), "copied into the package: {copies:?} ({r:?})"); + let r = r.unwrap(); + assert_eq!(r["missing"], json!(["readme"]), "{r}"); +} + +fn walkdir(d: &Path) -> Vec { + let mut out = Vec::new(); + for e in std::fs::read_dir(d).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { out.extend(walkdir(&p)) } else { out.push(p) } + } + out +} diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 29ce479c51..5ba3a26110 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -416,7 +416,7 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result< *l = l.duplicate(); } crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?; - crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy); + crate::layer_copy_cmds::translate_copies(&mut copies, &d.doc, dx, dy); let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect(); // A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584). let mut linked = Vec::new(); @@ -450,15 +450,35 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result< /// How far a paste moves the clipboard pixels: not at all in place (or when they lie on the /// canvas and no `center` is given), else onto `center` (the view centre from the UI) or the -/// canvas centre. +/// canvas centre, then kept on the canvas as Photoshop keeps it: pixels that fit stay wholly +/// inside, larger ones start at the canvas's top-left corner (#6759). `"clampToCanvas": false` +/// skips that, for Paste Into / Outside, which centre on the selection. pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) { if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) { return (0, 0); } let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))); - let (cx, cy) = c.unwrap_or(((f64::from(canvas.x0) + f64::from(canvas.x1)) / 2.0, (f64::from(canvas.y0) + f64::from(canvas.y1)) / 2.0)); + let (cx, cy) = c.unwrap_or((mid(canvas.x0, canvas.x1), mid(canvas.y0, canvas.y1))); let b = clip.bounds; - ((cx - (f64::from(b.x0) + f64::from(b.x1)) / 2.0).round() as i32, (cy - (f64::from(b.y0) + f64::from(b.y1)) / 2.0).round() as i32) + let (dx, dy) = ((cx - mid(b.x0, b.x1)).round(), (cy - mid(b.y0, b.y1)).round()); + if p.get("clampToCanvas").and_then(Value::as_bool) == Some(false) || b.is_empty() { + return (dx as i32, dy as i32); + } + (clamp_shift(dx, b.x0, b.x1, canvas.x0, canvas.x1), clamp_shift(dy, b.y0, b.y1, canvas.y0, canvas.y1)) +} + +/// The midpoint of `a..b`, each edge converted before adding so extreme coordinates can't overflow. +fn mid(a: i32, b: i32) -> f64 { + (f64::from(a) + f64::from(b)) / 2.0 +} + +/// Shift `d` of the span `b0..b1` along one axis, changed as little as needed to keep the span +/// inside `c0..c1` when it fits; a span longer than the canvas starts at `c0`. +fn clamp_shift(d: f64, b0: i32, b1: i32, c0: i32, c1: i32) -> i32 { + let (b0, b1, c0, c1) = (i64::from(b0), i64::from(b1), i64::from(c0), i64::from(c1)); + // `as` saturates a NaN or out-of-range shift, then the clamp brings it into range. + let start = if b1 - b0 <= c1 - c0 { b0.saturating_add(d as i64).clamp(c0, c1 - (b1 - b0)) } else { c0 }; + i32::try_from(start - b0).unwrap_or(if start > b0 { i32::MAX } else { i32::MIN }) } pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface { @@ -695,7 +715,7 @@ fn auto_adjust(s: &mut Session, kind: &str) -> Result { let sel = doc.selection.clone(); let mode = doc.mode; let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; - crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode); + crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode).map_err(EngineError::Other)?; Ok(()) })?; Ok(Value::Null) @@ -994,7 +1014,7 @@ pub fn specs() -> Vec { &["Edit"], Some("Cmd+V"), concat!( - r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"#, + r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"clampToCanvas":bool=true (keep the paste on the canvas, or at its top-left when larger),"#, paste_target!(), "} (with no document open: a new document from the clipboard)" ), @@ -1288,6 +1308,78 @@ mod tests { assert_eq!(s.active().unwrap().doc.layers.len(), 4); } + /// A `w`×`h` document with an opaque `cw`×`ch` image from another app on the clipboard. + fn external_clip_session(w: i32, h: i32, cw: i32, ch: i32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + let mut surface = Surface::new(PixelFormat::RGBA8); + surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]); + s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None }); + s + } + + /// #6759: wherever the view is centred, a paste that fits the canvas stays wholly on it. + #[test] + fn paste_that_fits_stays_on_the_canvas() { + for (w, h, center, want) in [ + (60, 100, [40.0, 45.0], Rect::new(0, 0, 60, 100)), + (60, 100, [-500.0, 900.0], Rect::new(0, 0, 60, 100)), + (100, 100, [10.0, 80.0], Rect::new(0, 0, 60, 100)), + (100, 100, [95.0, 50.0], Rect::new(40, 0, 100, 100)), + (100, 120, [50.0, 60.0], Rect::new(20, 10, 80, 110)), + ] { + let mut s = external_clip_session(w, h, 60, 100); + s.execute("edit.paste", json!({ "center": center })).unwrap(); + assert_eq!(active_bounds(&s), want, "{w}x{h} canvas, view centre {center:?}"); + } + } + + /// #6759: a paste larger than the canvas starts at its top-left corner, wherever the view is. + #[test] + fn paste_larger_than_the_canvas_starts_at_its_top_left() { + for center in [[0.0, 0.0], [25.0, 20.0], [1000.0, 1000.0], [-1000.0, 30.0]] { + let mut s = external_clip_session(50, 40, 60, 100); + s.execute("edit.paste", json!({ "center": center })).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(0, 0, 60, 100), "view centre {center:?}"); + } + // Wider but not taller: at the left edge, and wholly inside vertically. + for (center, want) in + [([500.0, 500.0], Rect::new(0, 100, 60, 200)), ([-500.0, -500.0], Rect::new(0, 0, 60, 100)), ([25.0, 120.0], Rect::new(0, 70, 60, 170))] + { + let mut s = external_clip_session(50, 200, 60, 100); + s.execute("edit.paste", json!({ "center": center })).unwrap(); + assert_eq!(active_bounds(&s), want, "view centre {center:?}"); + } + } + + /// Paste Into centres on the selection even where that runs off the canvas. + #[test] + fn paste_into_is_not_clamped_to_the_canvas() { + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 90, "y": 90, "width": 10, "height": 10})).unwrap(); + s.execute("edit.pasteSpecial.pasteInto", json!({})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(75, 85, 115, 105)); + } + + /// The clamp never overflows or panics: NaN, infinite and `i32`-limit shifts saturate (Rule 9). + #[test] + fn paste_offset_clamp_survives_extreme_coordinates() { + assert_eq!(clamp_shift(f64::NAN, 0, 10, 0, 100), 0); + assert_eq!(clamp_shift(f64::INFINITY, 0, 10, 0, 100), 90); + assert_eq!(clamp_shift(f64::NEG_INFINITY, i32::MIN, i32::MAX, 0, 1), i32::MAX); + assert_eq!(clamp_shift(1e30, i32::MIN, i32::MIN + 1, i32::MAX - 1, i32::MAX), i32::MAX); + let clip = Clip { + surface: Surface::new(PixelFormat::RGBA8), + bounds: Rect::new(i32::MAX - 2, i32::MAX - 2, i32::MAX, i32::MAX), + layers: None, + resolution: None, + }; + let (dx, dy) = paste_offset(&clip, Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false); + assert_eq!((dx, dy), (i32::MIN, i32::MIN), "an unrepresentable shift saturates instead of wrapping"); + } + #[test] fn paste_with_malformed_center_does_not_panic() { // A short/empty `center` array must not panic (Rule 9) — it falls back to the canvas centre. @@ -1932,6 +2024,78 @@ mod tests { assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits"); } + #[test] + fn cross_document_group_paste_renders_shapes_on_destination_canvas() { + for (copy_to_document, depth) in [(false, 8), (true, 16)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":100,"height":100})).unwrap(); + let mut ids = Vec::new(); + for x in [10, 50] { + s.execute("shape.create", json!({"kind":"rect","rect":[x,20,20,30],"fill":"#ff0000"})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap(); + ids.push(s.active().unwrap().active_layer.unwrap()); + } + s.active_mut().unwrap().selected_layers = ids; + s.execute("layer.groupLayers", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("file.new", json!({"width":800,"height":600,"depth":depth})).unwrap(); + if copy_to_document { + s.execute("layer.copyToDocument", json!({"source":0,"document":1,"center":true})).unwrap(); + } else { + s.execute("edit.paste", json!({"center":[400,300]})).unwrap(); + } + let st = s.active().unwrap(); + let group = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert!(!crate::transform_cmds::transform_bounds(&st.doc, group).is_empty()); + assert_eq!(group.children().unwrap().len(), 2); + for child in group.children().unwrap() { + let LayerContent::Shape(shape) = &child.content else { panic!("shape") }; + let cache = shape.cache.as_ref().unwrap(); + assert_eq!(cache.format().sample, st.doc.depth); + assert!(!cache.content_bounds().is_empty(), "destination cache must not be clipped to source canvas"); + assert!(cache.content_bounds().x0 > 100); + let fresh = photocraft_vector::render_shape(shape, st.doc.pixel_format(), st.doc.bounds()); + assert_eq!(cache, &fresh); + } + let before = st.doc.clone(); + s.execute("layer.translate", json!({"dx":10,"dy":10})).unwrap(); + assert_ne!(s.active().unwrap().doc, before); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } + + #[test] + fn paste_repairs_uncached_shapes_before_transform_and_effect_rendering() { + let mut s = session(); + s.execute("shape.create", json!({"kind":"rect","rect":[10,10,40,20],"fill":"#ff0000"})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let layers = s.clipboard.as_mut().unwrap().layers.as_mut().unwrap(); + let LayerContent::Shape(sh) = &mut layers.layers[0].content else { panic!("shape") }; + sh.cache = None; + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + let st = s.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + let LayerContent::Shape(sh) = &layer.content else { panic!("shape") }; + assert!(sh.cache.is_some()); + assert!(!crate::transform_cmds::transform_bounds(&st.doc, layer).is_empty()); + let expected = photocraft_vector::render_shape(sh, st.doc.pixel_format(), st.doc.bounds()); + assert_eq!(sh.cache.as_ref().unwrap(), &expected); + let rendered = photocraft_compose::render_layer_in(&st.doc, layer, st.doc.bounds()); + let mut fresh_doc = (*st.doc).clone(); + let snapshot = fresh_doc.clone(); + if let LayerContent::Shape(shape) = &mut fresh_doc.layer_mut(layer.id).unwrap().content { + crate::vector_cmds::refresh_shape(&snapshot, shape); + } + let fresh = photocraft_compose::render_layer_in(&fresh_doc, fresh_doc.layer(layer.id).unwrap(), fresh_doc.bounds()); + assert_eq!(rendered.px, fresh.px, "pasted effects match a freshly rebuilt shape"); + assert!(s.undo()); + assert!(s.redo()); + let st = s.active().unwrap(); + assert!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().is_some()); + } + /// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy /// takes them whole and Paste recreates them, in the same or another document, in one step. #[test] diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index c702b16b75..129753ea86 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -170,7 +170,7 @@ pub fn live_background_eraser(s: &Session, p: &Value) -> Result Box::new(effect), // No alpha: the commit erases nothing either. None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 4e2a265ab9..db5c93fcfe 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -336,6 +336,9 @@ fn can_reselect(s: &Session) -> std::result::Result<(), String> { reselect_target(s).map(|_| ()).ok_or_else(|| "there is no selection to restore".into()) } +/// Edit › Paste Special › Paste Into (or Outside): the clipboard centred on the selection, not +/// clamped to the canvas, as a new layer masked to the selection (or its inverse), or into the +/// targeted mask or channel. One history step. fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let selection = d.doc.selection.clone().ok_or(EngineError::Other("Paste Into needs a selection".into()))?; @@ -353,7 +356,8 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result { selection }; let label = if outside { "Paste Outside" } else { "Paste Into" }; - let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0]}); + // Centred on the selection, even where that runs off the canvas (not clamped like a paste). + let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0], "clampToCanvas": false}); if let Some(c) = p.get("center") { params["center"] = c.clone(); } @@ -366,6 +370,13 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result { params["target"] = p.get("target").cloned().unwrap_or_default(); return crate::edit_cmds::paste_to_target(s, ¶ms, false, Some(&limit), label); } + // The pasted layer lands beside the active one, inside its group, and a Lock-All group refuses + // its layer mask: refuse before pasting rather than leave an unmasked paste behind. + let d = s.active().ok_or(EngineError::NoDocument)?; + let group = d.active_layer.and_then(|a| d.doc.path_of(a)).and_then(|p| d.doc.layer_at(p.split_last()?.1).map(|g| g.id)); + if let Some(g) = group { + crate::commands::check_all_unlocked(&d.doc, g)?; + } let key = step_key(s, "pasteInto"); params["coalesce"] = json!(key); params["target"] = json!("pixels"); diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 406bdbe499..261e3d8608 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -229,7 +229,8 @@ pub(crate) fn stem(path: &str) -> String { /// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on /// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them). -pub fn same_file(a: &str, b: &str) -> bool { +pub fn same_file(a: impl AsRef, b: impl AsRef) -> bool { + let (a, b) = (a.as_ref(), b.as_ref()); if a == b { return true; } @@ -510,6 +511,8 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R psd_maximize_compatibility: save.psd_maximize_compatibility, // Linked files a PSD embeds are resolved against the document being worked on. document_path: crate::doc_path::current(), + // An untrusted session's stored paths are document data: embed none of them. + read_linked_files: !crate::jobs::authorization_installed(), ..Default::default() }; // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP @@ -574,6 +577,17 @@ pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface { photocraft_compose::flatten_to_surface(doc, fmt, None) } +/// [`flattened`] for documents that came from a file (Save a Copy, Place, Load Files into Stack): +/// a sparse bundle declares its canvas, and one the size of the format allows would be rendered +/// in full, however little it holds. Refused past the compositor's own per-render limit. +pub(crate) fn flattened_checked(doc: &Document, fmt: PixelFormat, cmd: &str) -> Result { + let b = doc.bounds(); + if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("the document's {}×{} canvas is too large to flatten", b.width(), b.height()) }); + } + Ok(flattened(doc, fmt)) +} + // ---------- close / revert / save a copy / open as ---------- fn close_all(s: &mut Session) -> Result { @@ -635,7 +649,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result { if p.get("layers").and_then(Value::as_bool) == Some(false) { // "Layers" unchecked in Save a Copy: write the flattened image. let fmt = doc.pixel_format(); - let px = flattened(&doc, fmt); + let px = flattened_checked(&doc, fmt, "file.saveACopy")?; doc.layers = vec![Layer::new("Background", LayerContent::Raster(px))]; } let warnings = save_doc(&doc, &path, SaveOpts::from_params(s, p, "file.saveACopy")?)?; @@ -742,7 +756,7 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option (cw / 2.0, ch / 2.0), }; let (dx, dy) = ((center.0 - w * scale / 2.0).round(), (center.1 - h * scale / 2.0).round()); - let mut px = flattened(&src, fmt); + let mut px = flattened_checked(&src, fmt, "file.place")?; if (scale - 1.0).abs() > 1e-9 { px = resize_surface(&px, scale, scale, Resample::Bicubic); } @@ -1114,7 +1128,7 @@ fn file_info(s: &mut Session, p: &Value) -> Result { let xmp = s.active().ok_or(EngineError::NoDocument)?.doc.metadata.xmp.clone(); let new = write_file_info(xmp.as_deref(), p); s.edit("File Info", |doc, _| { - doc.metadata.xmp = Some(new); + doc.metadata.xmp = Some(new.into()); Ok(()) })?; } @@ -1453,42 +1467,72 @@ fn batch(s: &mut Session, p: &Value) -> Result { list } }; - let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() }; + // A batch inside a played action charges its inputs × steps (at least one each: an input is read, + // decoded and saved even with no steps) to the playback tree it is in and + // hands the count on, so a batch of batches can't multiply past the limit. A top-level batch + // is not limited by it. + let mut playback_steps = 0; + if s.actions.playing > 0 { + playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(inputs.len() as u64)); + if playback_steps > crate::actions_cmds::MAX_PLAYBACK_STEPS { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "this batch would exceed the playback step limit of the action that runs it".into() }); + } + s.actions.playback_steps = playback_steps; + } + let actions = crate::actions_cmds::ActionState { + list, + playing: s.actions.playing, + playback_stack: s.actions.playback_stack.clone(), + playback_steps, + ..Default::default() + }; let names = batch_naming::plan(&inputs, &output, &format, p)?; let save = SaveOpts::from_params(s, p, cmd)?; let authorize = s.authorize; + // Each input runs in its own scratch session, so what the played steps spend is carried from + // one input to the next (failed or not) and back to this session, not restarted per input. + let carried = std::cell::Cell::new(playback_steps); + let carried = &carried; let run = move |scratch: &mut Session, out: &str| { scratch.authorize = authorize; scratch.actions = actions.clone(); - // The document as the last recorded save found it, written once the action has run so a - // failing step still writes nothing. - let mut saved: Option> = None; - for (id, params) in &steps { - if batch_save_point(id) { - saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone()); - continue; + scratch.actions.playback_steps = carried.get(); + let result = (|| { + // The document as the last recorded save found it, written once the action has run so a + // failing step still writes nothing. + let mut saved: Option> = None; + for (id, params) in &steps { + if batch_save_point(id) { + saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone()); + continue; + } + let r = scratch.execute(id, params.clone())?; + // A failed step inside a called action makes this file an error, not a saved result. + if let Some(error) = crate::actions_cmds::nested_failure(id, &r) { + return Err(EngineError::Other(error)); + } } - let r = scratch.execute(id, params.clone())?; - // A failed step inside a called action makes this file an error, not a saved result. - if let Some(error) = crate::actions_cmds::nested_failure(id, &r) { - return Err(EngineError::Other(error)); + // While the saved document is still open the steps after the save reach the result too + // (#3058), so it is saved after the action as usual. Closed after its save, or with another + // document left active, it is written as the save found it. + match saved { + Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => { + save_doc(&doc, out, save)?; + Ok(true) + } + _ => Ok(false), } - } - // While the saved document is still open the steps after the save reach the result too - // (#3058), so it is saved after the action as usual. Closed after its save, or with another - // document left active, it is written as the save found it. - match saved { - Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => { - save_doc(&doc, out, save)?; - Ok(true) - } - _ => Ok(false), - } + })(); + carried.set(scratch.actions.playback_steps); + result }; let r = match names { Some(names) => process_outputs(s, inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), None => process_tree(s, &inputs, &output, &format, save, "", &run), }; + if s.actions.playing > 0 { + s.actions.playback_steps = carried.get(); + } Ok(r) } @@ -1538,7 +1582,7 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result { let fmt = stack.pixel_format(); // First file at the bottom, like Photoshop's script. for (name, d) in &docs { - stack.layers.push(Layer::new(name.clone(), LayerContent::Raster(flattened(d, fmt)))); + stack.layers.push(Layer::new(name.clone(), LayerContent::Raster(flattened_checked(d, fmt, cmd)?))); } let n = stack.layers.len(); let smart = p.get("createSmartObject").and_then(Value::as_bool).unwrap_or(false); @@ -1666,41 +1710,77 @@ fn flatten_all_masks(s: &mut Session) -> Result { fn layers_to_files(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.layersToFiles"; let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string(); - let names = export_layers_with(s, p, |name, bytes| write_file(&join(&dir, name), bytes))?; - let files: Vec = names.iter().map(|name| join(&dir, name)).collect(); - Ok(json!({"files": files})) + let report = export_layers_report_with(s, p, |name, bytes| write_file(&join(&dir, name), bytes))?; + let files: Vec = report.files.iter().map(|name| join(&dir, name)).collect(); + Ok(json!({"files": files, "skipped": report.skipped})) } /// The bodies of `file.export.layersToFiles`, streamed to `write` as a single-component filename /// plus its bytes: one file per exported layer (top to bottom, like Photoshop's numbering), /// encoded but never touched as a path. Automation writes exactly these bytes beneath its write /// root (#3216), so the two paths cannot drift. Returns the filenames written, in order. -pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result> { +pub fn export_layers_with(s: &Session, p: &Value, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result> { + export_layers_report_with(s, p, write).map(|report| report.files) +} + +/// A top-level layer omitted from a successful Layers to Files export. +#[derive(Debug, serde::Serialize)] +pub struct SkippedExportLayer { + /// Session layer id (names need not be unique). + pub layer: u64, + /// Original name, not the sanitized filename component. + pub name: String, + /// Why the layer produced no file. + pub reason: &'static str, +} + +/// Filenames written and layers skipped, each in top-to-bottom order. +#[derive(Debug, Default)] +pub struct LayerExportReport { + pub files: Vec, + pub skipped: Vec, +} + +/// The same export as [`export_layers_with`], with an explanation for every omitted top-level +/// layer. Groups export merged, so their children are not separate exports or skips. Encoding +/// and write failures remain errors, never successful skips. Existing numbering is preserved. +pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = "file.export.layersToFiles"; let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); + // Callers outside `Session::execute` (headless workspace exports) carry the session's gate + // into `encode`, so a PSD embeds no file a linked smart object names. + let _authorization = crate::jobs::render_authorization_scope(s.authorize); let save = SaveOpts::from_params(s, p, cmd)?; let d = s.active().ok_or(EngineError::NoDocument)?; let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name)); let doc = d.doc.clone(); - let mut written = Vec::new(); - // Top-level layers (groups export merged), top to bottom like Photoshop's numbering. - let layers: Vec<&Layer> = doc.layers.iter().rev().filter(|l| l.visible || !visible_only).collect(); - for (i, l) in layers.iter().enumerate() { + let mut report = LayerExportReport::default(); + // Hidden layers never consumed a filename index; adjustments that pass the visibility + // filter did. Keep those filenames stable while reporting both kinds of omission. + let mut i = 0usize; + for l in doc.layers.iter().rev() { + if !l.visible && visible_only { + report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "hidden by visibleOnly" }); + continue; + } + let index = i; + i += 1; if matches!(l.content, LayerContent::Adjustment(_)) { + report.skipped.push(SkippedExportLayer { layer: l.id.0, name: l.name.clone(), reason: "adjustment layer has no pixels of its own" }); continue; } let mut one = (*doc).clone(); - let mut only = (*l).clone(); + let mut only = l.clone(); only.visible = true; only.clipped = false; one.layers = vec![only]; - let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&l.name)); + let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)); let (bytes, _warnings) = encode(&one, &name, save)?; write(&name, &bytes)?; - written.push(name); + report.files.push(name); } - Ok(written) + Ok(report) } /// Adjustment layers that can be represented as an RGB-only LUT, in document stack order. @@ -2056,7 +2136,7 @@ pub fn specs() -> Vec { "New Guide Layout…", &["View"], None, - r##"{"columns":n=0,"width":px?,"gutter":px=0,"rows":n=0,"height":px?,"rowGutter":px=gutter,"margin":px|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"}"##, + r##"{"columns":n=0,"width":len?,"gutter":len=0,"rows":n=0,"height":len?,"rowGutter":len=gutter,"margin":len|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"} (len: pixels, or text with px, in, cm, mm, pt, pc or % as typed in Photoshop, e.g. "1 cm"; % is of the target's width for columns, gutters and left/right margins, its height for rows and top/bottom margins)"##, has_doc, new_guide_layout ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3115d713b6..2a23dde9f1 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -771,7 +771,7 @@ fn file_info_translations_survive_undo_and_psd_round_trip() { for depth in [8, 16, 32] { let mut s = session(8, 8, depth); s.edit("Set metadata", |doc, _| { - doc.metadata.xmp = Some(MULTILINGUAL_XMP.to_string()); + doc.metadata.xmp = Some(MULTILINGUAL_XMP.into()); Ok(()) }) .unwrap(); @@ -781,7 +781,7 @@ fn file_info_translations_survive_undo_and_psd_round_trip() { assert!(s.undo()); assert_eq!(doc(&s).metadata.xmp.as_deref(), Some(MULTILINGUAL_XMP)); assert!(s.redo()); - assert_eq!(doc(&s).metadata.xmp.as_deref(), Some(edited.as_str())); + assert_eq!(doc(&s).metadata.xmp.as_deref(), Some(&*edited)); let (bytes, _) = encode(doc(&s), "x.psd", None).unwrap(); let back = photocraft_io::import("x.psd", &bytes).unwrap().document; assert_translations_kept(back.metadata.xmp.as_deref().unwrap()); @@ -1080,6 +1080,69 @@ fn flatten_all_layer_effects_keeps_a_pattern_overlay() { } } +#[test] +fn layers_to_files_reports_skipped_layers_without_changing_files() { + for depth in [8, 16, 32] { + let dir = tmp(&format!("skipped-layers-{depth}")); + let mut s = session(4, 4, depth); + let adjustment = s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap()["layer"].clone(); + s.execute("layer.setProps", json!({"name": "Levels/α"})).unwrap(); + let hidden = s.execute("layer.new.layer", json!({"name": "Levels/α"})).unwrap()["layer"].clone(); + s.execute("layer.setProps", json!({"visible": false})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let result = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "doc"})).unwrap(); + assert_eq!( + result["skipped"], + json!([ + {"layer": hidden, "name": "Levels/α", "reason": "hidden by visibleOnly"}, + {"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"} + ]) + ); + assert_eq!(result["files"], json!([join(&dir, "doc_0001_Background.png")])); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + assert_eq!(s.active().unwrap().doc.layers.len(), before.layers.len()); + assert!(!s.active().unwrap().doc.layer(LayerId(hidden.as_u64().unwrap())).unwrap().visible); + let all = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": "all", "visibleOnly": false})).unwrap(); + assert_eq!( + all["skipped"], + json!([ + {"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"} + ]) + ); + assert_eq!(all["files"], json!([join(&dir, "all_0000_Levels_α.png"), join(&dir, "all_0002_Background.png")])); + } +} + +#[test] +fn layers_to_files_reports_groups_as_exports_and_failures_as_errors() { + let mut s = session(4, 4, 8); + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + s.execute("layer.new.groupFromLayers", json!({"name": "Pack"})).unwrap(); + let params = json!({"prefix": "doc"}); + let mut names = Vec::new(); + let report = export_layers_report_with(&s, ¶ms, |name, bytes| { + assert!(!bytes.is_empty()); + names.push(name.to_string()); + Ok(()) + }) + .unwrap(); + assert_eq!(names, vec!["doc_0000_Pack.png", "doc_0001_Background.png"]); + assert_eq!(report.files, names); + assert!(report.skipped.is_empty(), "a group's adjustment is not an omitted top-level layer"); + assert_eq!(export_layers_with(&s, ¶ms, |_, _| Ok(())).unwrap(), names, "legacy Rust helper remains compatible"); + assert!(export_layers_report_with(&s, ¶ms, |_, _| Err(EngineError::Other("disk full".into()))).unwrap_err().to_string().contains("disk full")); + assert!(export_layers_report_with(&s, &json!({"format": "not-a-format"}), |_, _| panic!("must not write")).is_err()); + assert!(export_layers_report_with(&Session::new(), ¶ms, |_, _| panic!("must not write")).is_err()); + s.edit("Empty", |doc, _| { + doc.layers.clear(); + Ok(()) + }) + .unwrap(); + let empty = export_layers_report_with(&s, ¶ms, |_, _| panic!("must not write")).unwrap(); + assert!(empty.files.is_empty()); + assert!(empty.skipped.is_empty()); +} + #[test] fn layers_to_files_writes_each_visible_layer() { let dir = tmp("layers"); @@ -1242,6 +1305,50 @@ fn exported_selected_cube_round_trips_through_lookup_importer() { assert_eq!(s.active().unwrap().doc.layers.len(), 3); } +/// #4984: New Guide Layout takes lengths with units as typed in Photoshop's dialog, at the +/// document's resolution; percent is of the target's width or height, by axis. +#[test] +fn guide_layouts_take_units() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1200, "height": 600, "resolution": 300})).unwrap(); + let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01); + // 1 cm at 300 ppi is 118.11 px; 0.25 in is 75 px. + s.execute("view.newGuideLayout", json!({"columns": 2, "gutter": "1 cm", "margin": "0.25in"})).unwrap(); + let g = &doc(&s).guides; + let col = (1050.0 - 118.110_236) / 2.0; + assert!(close(&g.vertical, &[75.0, 1125.0, 75.0 + col, 75.0 + col + 118.110_236]), "{:?}", g.vertical); + assert!(close(&g.horizontal, &[75.0, 525.0]), "{:?}", g.horizontal); + // Percent of the width for columns, of the height for rows and top/bottom margins; points + // follow Units & Rulers (72 per inch: 36 pt is 150 px, centred like the columns). + s.execute( + "view.newGuideLayout", + json!({"columns": 1, "width": "25%", "centerColumns": true, "rows": 1, "height": "36 pt", "margin": ["10%", 0, "10%", 0], "clearExisting": true}), + ) + .unwrap(); + let g = &doc(&s).guides; + assert!(close(&g.vertical, &[0.0, 1200.0, 450.0, 750.0]), "{:?}", g.vertical); + assert!(close(&g.horizontal, &[60.0, 540.0, 225.0, 375.0]), "{:?}", g.horizontal); + // With selected artboards, percent is of each board, not of the document. + s.execute("layer.new.artboard", json!({"rect": [200, 100, 140, 40]})).unwrap(); + s.execute( + "view.newGuideLayout", + json!({"columns": 1, "width": "50%", "centerColumns": true, "rows": 1, "height": "50%", "target": "selectedArtboards", "clearExisting": true}), + ) + .unwrap(); + let g = &doc(&s).guides; + // Clear Existing removes guides inside the board only; those outside it stay. + assert!(close(&g.vertical, &[0.0, 1200.0, 450.0, 750.0, 235.0, 305.0]), "{:?}", g.vertical); + assert!(close(&g.horizontal, &[60.0, 540.0, 225.0, 375.0, 110.0, 130.0]), "{:?}", g.horizontal); + for bad in [ + json!({"columns": 2, "gutter": "1 furlong"}), + json!({"columns": 2, "gutter": "-1 cm"}), + json!({"columns": 2, "margin": [0, "x", 0, 0]}), + json!({"columns": 2, "gutter": true}), + ] { + assert!(s.execute("view.newGuideLayout", bad.clone()).is_err(), "{bad}"); + } +} + #[test] fn guide_layouts() { let mut s = session(100, 50, 8); @@ -1416,6 +1523,18 @@ fn reveal_in_finder_reveals_the_documents_saved_path() { assert_eq!(r["args"], json!(args)); } +#[cfg(windows)] +#[test] +fn reveal_in_finder_on_windows_passes_the_clicked_tabs_saved_path() { + let mut s = session(8, 8, 8); + s.active_mut().unwrap().path = Some("C:/项目 图片/海报 🖼.png".into()); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "other"})).unwrap(); + + let r = s.execute("file.revealInFinder", json!({"document": 0, "dryRun": true})).unwrap(); + assert_eq!(r["program"], "explorer"); + assert_eq!(r["args"], json!(["/select,", r"C:\项目 图片\海报 🖼.png"])); +} + /// "Include All Subfolders" (AUTO-219-5, #3753): Batch and Image Processor take the images in /// subfolders too, each result keeping its subfolder under the output folder (so same-named files /// don't collide), and skip the output folder when it sits inside the input folder. diff --git a/crates/engine/src/guide_layout.rs b/crates/engine/src/guide_layout.rs index 4a4261faff..6c4eb89c00 100644 --- a/crates/engine/src/guide_layout.rs +++ b/crates/engine/src/guide_layout.rs @@ -4,6 +4,7 @@ use photocraft_doc::Guides; use photocraft_geom::Rect; use serde_json::Value; +use crate::prefs::Unit; use crate::{EngineError, Result, Session}; const COMMAND: &str = "view.newGuideLayout"; @@ -14,10 +15,46 @@ fn bad(message: impl Into) -> EngineError { EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() } } -fn number(p: &Value, key: &str, default: f64) -> Result { +/// A length as given: a number of pixels, or text with a unit as typed in Photoshop's New Guide +/// Layout fields ("2 cm", "0.25in", "12 pt", "10%"), resolved against each target (#4984). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Length { + Px(f64), + In(f64, Unit), +} + +impl Length { + /// Pixels at the document's `dpi`; percent is of `extent` (the target's width for column + /// sizes and left/right margins, its height for row sizes and top/bottom margins). + fn px(self, key: &str, dpi: f64, extent: f64, ppi: f64) -> Result { + let v = match self { + Length::Px(v) => v, + Length::In(v, unit) => unit.to_px(v, dpi, extent, ppi), + }; + if v.is_finite() && v >= 0.0 { Ok(v) } else { Err(bad(format!("{key} must be a finite non-negative length"))) } + } +} + +fn length_of(key: &str, v: &Value) -> Result { + let invalid = || bad(format!("{key} must be a non-negative number of pixels, or text with px, in, cm, mm, pt, pc or %")); + let length = match (v.as_f64(), v.as_str()) { + (Some(n), _) => Length::Px(n), + (None, Some(text)) => match Unit::parse_length(text).ok_or_else(invalid)? { + (n, None | Some(Unit::Pixels)) => Length::Px(n), + (n, Some(unit)) => Length::In(n, unit), + }, + _ => return Err(invalid()), + }; + match length { + Length::Px(n) | Length::In(n, _) if n.is_finite() && n >= 0.0 => Ok(length), + _ => Err(invalid()), + } +} + +fn number(p: &Value, key: &str, default: Length) -> Result { match p.get(key) { None | Some(Value::Null) => Ok(default), - Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0).ok_or_else(|| bad(format!("{key} must be a finite non-negative number"))), + Some(v) => length_of(key, v), } } @@ -110,9 +147,10 @@ pub fn calculate(s: &Session, p: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let cols = count(p, "columns")?; let rows = count(p, "rows")?; - let width = number(p, "width", 0.0)?; - let height = number(p, "height", 0.0)?; - let gap = number(p, "gutter", 0.0)?; + let zero = Length::Px(0.0); + let width = number(p, "width", zero)?; + let height = number(p, "height", zero)?; + let gap = number(p, "gutter", zero)?; let row_gap = number(p, "rowGutter", gap)?; let center = flag(p, "centerColumns")?; let clear = flag(p, "clearExisting")?; @@ -121,15 +159,16 @@ pub fn calculate(s: &Session, p: &Value) -> Result { if a.len() != 4 { return Err(bad("margin must contain top, left, bottom and right")); } - let mut m = [0.0; 4]; + let mut m = [zero; 4]; for (out, value) in m.iter_mut().zip(a) { - *out = value.as_f64().filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| bad("margins must be finite non-negative numbers"))?; + *out = length_of("margin", value)?; } m } - _ => [number(p, "margin", 0.0)?; 4], + _ => [number(p, "margin", zero)?; 4], }; - let [top, left, bottom, right] = margins; + let dpi = f64::from(st.doc.resolution_dpi); + let ppi = s.prefs().units_and_rulers.point_size.per_inch(); let bounds = targets(s, p)?; // Bound the aggregate work as well as each count, including multi-artboard requests. if (cols + rows + 4).saturating_mul(2).saturating_mul(bounds.len() as u64) > MAX_EDGES { @@ -137,16 +176,22 @@ pub fn calculate(s: &Session, p: &Value) -> Result { } let mut added = Guides::default(); for rect in &bounds { + let (across, down) = (f64::from(rect.width()), f64::from(rect.height())); + let [top, left, bottom, right] = + [(margins[0], down), (margins[1], across), (margins[2], down), (margins[3], across)].map(|(m, extent)| m.px("margin", dpi, extent, ppi)); + let (top, left, bottom, right) = (top?, left?, bottom?, right?); let (x0, y0, x1, y1) = (f64::from(rect.x0) + left, f64::from(rect.y0) + top, f64::from(rect.x1) - right, f64::from(rect.y1) - bottom); if x0 >= x1 || y0 >= y1 { return Err(bad("margins must leave space inside the target")); } - if margins.iter().any(|x| *x != 0.0) { + if [top, left, bottom, right].iter().any(|x| *x != 0.0) { push_unique(&mut added.vertical, x0)?; push_unique(&mut added.vertical, x1)?; push_unique(&mut added.horizontal, y0)?; push_unique(&mut added.horizontal, y1)?; } + let (width, gap) = (width.px("width", dpi, across, ppi)?, gap.px("gutter", dpi, across, ppi)?); + let (height, row_gap) = (height.px("height", dpi, down, ppi)?, row_gap.px("rowGutter", dpi, down, ppi)?); axis(x0, x1, cols, width, gap, center, &mut added.vertical)?; axis(y0, y1, rows, height, row_gap, center, &mut added.horizontal)?; } diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index d52522ca35..6353f1f2cc 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -1,6 +1,8 @@ //! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and //! bit depth) conversions, Duplicate. +use std::convert::Infallible; + use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size}; @@ -297,6 +299,7 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl } // Content that reaches the canvas edge keeps it: a Background stays opaque to the border. let canvas = Rect::from_xywh(0, 0, ow, oh); + let taken = crate::smart_cmds::take_caches(&mut doc.layers); for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| { *surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas); }); @@ -321,6 +324,7 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); doc.size = Size::new(nw, nh); crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); + let Ok(()) = crate::smart_cmds::restore_unrendered(doc, taken, |c| Ok::<_, Infallible>(resize_surface_in_canvas(c, sx, sy, filter, canvas))); } fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -371,6 +375,26 @@ pub(crate) fn translate_doc(doc: &mut Document, cmd: &str, dx: i32, dy: i32) -> Ok(()) } +/// Delete Cropped Pixels on a layer tree: crops pixel-layer data only to `r`. Type and smart +/// objects move by shifting their rendered pixels, so cutting those would clip them at the old +/// edge. A video layer's stored frames are its pixels too: the displayed content is only the frame +/// at the playhead, so the others would bring the cropped pixels back when scrubbing (#4956). +fn delete_cropped_pixels(layers: &mut [Layer], r: Rect) { + for_each_layer(layers, &mut |l| { + if let Some(fc) = &mut l.fill_cache { + fc.surface = crop_surface(&fc.surface, r); + } + if let Some(v) = &mut l.video { + for frame in v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()) { + *frame = crop_surface(frame, r); + } + } + if let LayerContent::Raster(s) = &mut l.content { + *s = crop_surface(s, r); + } + }); +} + /// Crops the document to `r` (in current document coordinates). /// A frame that extends past the canvas fills the Background's new area with `extension` (the /// background colour, as Photoshop's Crop tool does); other layers get transparent pixels there. @@ -382,7 +406,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi let old_canvas = doc.bounds(); let extends = r.x0 < old_canvas.x0 || r.y0 < old_canvas.y0 || r.x1 > old_canvas.x1 || r.y1 > old_canvas.y1; if delete_pixels { - for_each_surface(&mut doc.layers, false, &mut |surf, _| *surf = crop_surface(surf, r)); + delete_cropped_pixels(&mut doc.layers, r); } translate_doc(doc, cmd, dx, dy)?; doc.size = Size::new(r.width(), r.height()); @@ -753,7 +777,14 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar if delete { let keep = turned_crop_source(size, new, angle, target); s.edit("Crop", |doc, _| { - for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = crop_surface(surf, keep)); + // Type and smart objects stay whole, as in the crop itself: the rotation re-renders + // them, but a smart object whose source is unavailable keeps its cache. + delete_cropped_pixels(&mut doc.layers, keep); + for_each_layer(&mut doc.layers, &mut |l| { + if let Some(m) = &mut l.mask { + m.surface = crop_surface(&m.surface, keep); + } + }); for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { ch.surface = crop_surface(&ch.surface, keep); } @@ -1093,6 +1124,37 @@ mod tests { } } + /// Delete Cropped Pixels on a shrinking crop deletes the cropped area from every stored video + /// frame (and the original footage), not just the displayed one (#4956). + #[test] + fn delete_cropped_pixels_crops_stored_video_frames() { + let mut s = session(); + s.edit("video", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let red = l.surface().unwrap().clone(); + let mut blue = red.clone(); + blue.fill_rect(Rect::new(0, 0, 40, 20), &[0.0, 0.0, 1.0, 1.0]); + let mut v = photocraft_doc::VideoData::new(vec![red.clone(), blue.clone()], 24.0); + v.original_frames = Some(vec![red, blue]); + l.video = Some(v); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": 15, "y": 0, "width": 25, "height": 20, "deleteCroppedPixels": true})).unwrap(); + assert_eq!(doc(&s).size, Size::new(25, 20)); + let v = doc(&s).layers[1].video.as_ref().unwrap(); + let originals = v.original_frames.as_ref().unwrap(); + assert_eq!((v.frames.len(), originals.len()), (2, 2)); + for frames in [&v.frames, originals] { + // The red square (10..20, 5..15) keeps only its right half; the full blue frame + // keeps exactly the 25×20 crop. + assert_eq!(frames[0].content_bounds(), Rect::new(0, 5, 5, 15)); + assert_eq!(frames[0].pixel(2, 10), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(frames[1].content_bounds(), Rect::new(0, 0, 25, 20)); + assert_eq!(frames[1].pixel(24, 19), vec![0.0, 0.0, 1.0, 1.0]); + } + } + /// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size /// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the /// resolution changes. @@ -1400,6 +1462,51 @@ mod tests { assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 8), [0.0; 4]); } + /// Delete Cropped Pixels deletes pixel-layer data only: type and smart objects keep what lies + /// outside the frame, so moving them later doesn't show them cut at the old edge. + #[test] + fn delete_cropped_pixels_keeps_type_and_smart_objects_whole() { + let mut s = session(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("type.create", json!({"x": 10, "y": 15, "text": "Hi", "size": 12})).unwrap(); + let bounds = |s: &Session| -> Vec { doc(s).layers[1..].iter().map(|l| l.surface().unwrap().content_bounds()).collect() }; + let before = bounds(&s); + assert!(before.iter().all(|b| b.x0 < 15 && b.x1 > 15), "both cross the crop edge: {before:?}"); + s.execute("image.crop", json!({"x": 15, "y": 0, "width": 25, "height": 20})).unwrap(); + assert!(matches!(doc(&s).layers[1].content, LayerContent::Smart(_)) && matches!(doc(&s).layers[2].content, LayerContent::Text(_))); + assert_eq!(bounds(&s), before.iter().map(|b| b.translate(-15, 0)).collect::>()); + } + + /// A turned crop deleting pixels keeps type and smart objects whole too, also a smart object + /// whose source is unavailable (the rotation can't re-render it, so it keeps its cache): each + /// ends up exactly as the same turned crop with Delete Cropped Pixels off leaves it. + #[test] + fn turned_crop_deleting_pixels_keeps_type_and_smart_objects_whole() { + let [deleted, kept] = [true, false].map(|delete| { + let mut s = session(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.edit("unlink", |doc, active| { + if let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content { + sm.source = photocraft_doc::SmartSource::Linked { path: "missing-photocraft-test-source.psd".into() }; + } + Ok(()) + }) + .unwrap(); + s.execute("type.create", json!({"x": 10, "y": 15, "text": "Hi", "size": 12})).unwrap(); + s.execute("image.crop", json!({"x": 20, "y": 0, "width": 20, "height": 20, "angle": 2.0, "deleteCroppedPixels": delete})).unwrap(); + s + }); + let (a, b) = (doc(&deleted), doc(&kept)); + assert!(matches!(a.layers[1].content, LayerContent::Smart(_)) && matches!(a.layers[2].content, LayerContent::Text(_))); + for i in [1, 2] { + let (sa, sb) = (a.layers[i].surface().unwrap(), b.layers[i].surface().unwrap()); + let r = sb.content_bounds(); + assert!(r.x0 < 0, "layer {i} reaches past the crop's left edge: {r:?}"); + assert_eq!(sa.content_bounds(), r, "layer {i}"); + assert_eq!(sa.read_region(r), sb.read_region(r), "layer {i}"); + } + } + /// The Background keeps the pixels a crop with Delete Cropped Pixels off hid: extending the /// canvas over them again (Reveal All, Canvas Size) shows them rather than painting them over /// with the background colour (#7086). diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index f708d856fd..e4c93c7cbc 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -224,7 +224,7 @@ mod tests { s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); s.edit("unsupported fixture", |doc, active| { - let l = Layer::new("Mystery", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "xyzw".into(), raw: vec![1, 2, 3] })); + let l = Layer::new("Mystery", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "xyzw".into(), raw: vec![1, 2, 3].into() })); let id = l.id; doc.layers.push(l); *active = Some(id); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 57846617dd..5c26e41cba 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -66,6 +66,11 @@ pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> { } } +/// Whether the work on this thread is done for an untrusted session (a gate is installed). +pub(crate) fn authorization_installed() -> bool { + RENDER_AUTHORIZATION.with(std::cell::Cell::get).is_some() +} + /// Identifies one background job for the session's lifetime. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)] pub struct JobId(pub u64); @@ -524,6 +529,19 @@ impl Session { /// result is `{held, name, warnings, color}` (`color` the question): take it with /// [`Session::take_held_open`] once answered. pub fn start_open(&mut self, name: &str, source: OpenSource) -> Result { + self.start_open_inner(name, source, false) + } + + /// Interactive opening: hold a damaged PSD until the user answers a damage + /// confirmation, without changing import or subsequent save behaviour. + /// Headless callers keep using [`Self::start_open`]. + pub fn start_open_for_user(&mut self, name: &str, source: OpenSource) -> Result { + self.start_open_inner(name, source, true) + } + + /// Decodes a file in a job and optionally holds damaged PSD content for + /// interactive confirmation while leaving headless opens non-interactive. + fn start_open_inner(&mut self, name: &str, source: OpenSource, confirm_damage: bool) -> Result { let label = format!("Opening {name}"); let name_w = name.to_string(); let name_a = name.to_string(); @@ -541,35 +559,36 @@ impl Session { }; ctx.check()?; ctx.progress(0.02, "Decoding"); - let imported = ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_with_options_and_interrupt(&name_w, &bytes, &opts, ctl)).map_err( - |e| match e { + let imported = + ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_for_open_with(&name_w, &bytes, &opts, ctl)).map_err(|e| match e { photocraft_io::IoError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), - }, - )?; + })?; ctx.progress(0.95, "Preparing layers"); - warm_layer_bounds(&imported.document, || ctx.check())?; + warm_layer_bounds(&imported.0.document, || ctx.check())?; // Type layers need the shared text engine, whose first use waits for the // installed-font scan started at launch (minutes on some machines). Wait here, // so the shell's missing-font check after the open finds it ready instead of // freezing the window (#7332). - if crate::type_extra_cmds::has_type_layers(&imported.document) { + if crate::type_extra_cmds::has_type_layers(&imported.0.document) { ctx.progress(0.97, "Loading fonts"); let _ = photocraft_text::shared(); ctx.check()?; } Ok(imported) }, - move |s, mut r: photocraft_io::ImportResult| { + move |s, (mut r, damaged): (photocraft_io::ImportResult, bool)| { // File Handling's ACEScg option, before the colour prompt looks at the profile. crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); crate::file_cmds::raw_defaults(s, &r.warnings, &mut r.document); // Ask When Opening: held back until the prompt is answered (`take_held_open`). A // developed raw has no embedded profile to ask about. let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); - if let Some(question) = s.color.open_question(&r.document).filter(|_| !raw) { + let damaged = confirm_damage && damaged; + let question = s.color.open_question(&r.document).filter(|_| !raw); + if damaged || question.is_some() { let held = s.hold_open(r.document, r.source_read_only); - return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question})); + return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question, "damaged": damaged})); } let (index, color) = s.open_document(r.document, None); // Persisted IDs can collide: report the identity the session actually admitted. @@ -1075,3 +1094,53 @@ pub fn specs() -> Vec { #[cfg(test)] #[path = "jobs_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "../../io/tests/support/recovery_psd.rs"] +mod psd_open_fixture; + +#[cfg(test)] +mod psd_open_confirmation_tests { + use super::*; + + /// Collects either an inline open result or the completed background-job result. + fn result(s: &mut Session, started: Started) -> Value { + match started { + Started::Done(v) => v, + Started::Job(id) => s.wait_job(id).unwrap(), + } + } + + /// Verifies headless opens return readable PSD content and warnings without a held prompt. + #[test] + fn headless_open_keeps_best_effort_psd_import() { + let mut s = Session::new(); + let started = s.start_open("damaged.psd", OpenSource::Bytes(Arc::new(super::psd_open_fixture::make_psd(true)))).unwrap(); + let v = result(&mut s, started); + assert!(v.get("document").is_some(), "{v}"); + assert!(v.get("held").is_none(), "no new prompt for a headless caller"); + assert!(!s.active().unwrap().source_read_only); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("could not be decoded"))); + } + + /// Verifies interactive opens hold damaged PSD files but admit valid files immediately. + #[test] + fn interactive_open_holds_only_a_damaged_psd() { + for corrupt in [false, true] { + let mut s = Session::new(); + let started = s.start_open_for_user("file.psd", OpenSource::Bytes(Arc::new(super::psd_open_fixture::make_psd(corrupt)))).unwrap(); + let v = result(&mut s, started); + if corrupt { + assert_eq!(v["damaged"], true); + assert!(s.documents().is_empty()); + let held = v["held"].as_u64().unwrap(); + let (doc, read_only) = s.take_held_open(held).unwrap(); + assert_eq!(doc.layers.len(), 2); + assert!(!read_only); + } else { + assert!(v.get("document").is_some(), "{v}"); + assert!(!s.active().unwrap().source_read_only); + } + } + } +} diff --git a/crates/engine/src/layer_copy_cmds.rs b/crates/engine/src/layer_copy_cmds.rs index d08955fdf6..78e72f0a7b 100644 --- a/crates/engine/src/layer_copy_cmds.rs +++ b/crates/engine/src/layer_copy_cmds.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet}; use photocraft_color::ColorMode; -use photocraft_doc::{Document, LayerContent, LayerId, SmartContentsId}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -59,6 +59,17 @@ fn center(r: Rect) -> [f64; 2] { [(f64::from(r.x0) + f64::from(r.x1)) / 2.0, (f64::from(r.y0) + f64::from(r.y1)) / 2.0] } +/// Restore missing shape caches, or refresh every shape for a changed canvas, including groups. +fn prepare_shapes(layers: &mut [Layer], doc: &Document, new_canvas: bool) { + for layer in layers { + match &mut layer.content { + LayerContent::Shape(shape) if new_canvas || shape.cache.is_none() => crate::vector_cmds::refresh_shape(doc, shape), + LayerContent::Group(group) => prepare_shapes(&mut group.children, doc, new_canvas), + _ => {} + } + } +} + /// Readies `copies` (a scratch document of the source's colour holding duplicated layers) for /// landing in `dest`: smart objects get fresh contents ids, keeping only the sharing within the /// batch (contents ids belong to a document, so the copies never join objects already in `dest`, @@ -107,13 +118,20 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document) *surf = surf.convert(f); }); } + let new_canvas = copies.bounds() != dest.bounds(); + prepare_shapes(&mut copies.layers, dest, new_canvas); Ok(()) } /// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement). -pub(crate) fn translate_copies(copies: &mut Document, dx: i32, dy: i32) { +pub(crate) fn translate_copies(copies: &mut Document, destination: &Document, dx: i32, dy: i32) { if dx != 0 || dy != 0 { - let snapshot = copies.clone(); + let mut snapshot = copies.clone(); + // Vector caches are clipped and quantized for the destination, not the clipboard's source. + snapshot.size = destination.size; + snapshot.mode = destination.mode; + snapshot.depth = destination.depth; + snapshot.icc_profile = destination.icc_profile.clone(); for l in &mut copies.layers { crate::commands::translate_layer(&snapshot, l, dx, dy); crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy)); @@ -172,11 +190,14 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { if let (Some(name), [l]) = (p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()), copies.layers.as_mut_slice()) { l.name = name.to_string(); } + // Measure in source space: destination refresh may clip a shape before it is moved. + // Missing caches must participate too, including shapes nested inside groups. + prepare_shapes(&mut copies.layers, &sdoc, false); + let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)); + let content = if bounds.is_empty() { sdoc.bounds() } else { bounds }; adapt_copies(s, &mut copies, &ddoc)?; // Placement: centred on the destination's canvas, centred on a point, or offset from where // the layers are in the source (default: the same canvas position). - let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)); - let content = if bounds.is_empty() { sdoc.bounds() } else { bounds }; let (dx, dy) = if p.get("center").and_then(Value::as_bool).unwrap_or(false) { let (c, d) = (center(content), center(ddoc.bounds())); (d[0] - c[0], d[1] - c[1]) @@ -187,7 +208,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { point(p, "offset")?.map_or((0.0, 0.0), |o| (o[0], o[1])) }; let (dx, dy) = (dx.round() as i32, dy.round() as i32); - translate_copies(&mut copies, dx, dy); + translate_copies(&mut copies, &ddoc, dx, dy); let patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect(); s.set_active(dest); let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" }; @@ -201,6 +222,8 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { new.push(id); } *active = above; + // Copied groups keep their own depth: repeated copies would otherwise nest without end. + crate::layer_multi_cmds::check_group_depth(doc, "Copy Layers")?; Ok(new) }); // Nothing changed: the source stays the active document. diff --git a/crates/engine/src/layer_copy_cmds/tests.rs b/crates/engine/src/layer_copy_cmds/tests.rs index 93bd7b94ef..b3200b16ef 100644 --- a/crates/engine/src/layer_copy_cmds/tests.rs +++ b/crates/engine/src/layer_copy_cmds/tests.rs @@ -342,3 +342,30 @@ fn a_named_duplicate_takes_the_name_in_one_step() { let r = s.execute("layer.copyToDocument", json!({"document": 1, "name": "Ink"})).unwrap(); assert_eq!(doc(&s, 1).layer(copied(&r)).unwrap().name, "Ink"); } + +#[test] +fn a_copied_group_cannot_nest_the_destination_past_the_group_depth_limit() { + use photocraft_doc::{Layer, MAX_GROUP_DEPTH}; + let (mut s, paint) = two_docs(40, 30, 8); + // A group in the source, and a destination whose active layer sits at the deepest legal level. + s.execute("layer.groupLayers", json!({"layers": [paint.0]})).unwrap(); + let group = s.active().unwrap().active_layer.unwrap(); + s.set_active(1); + let mut inner = None; + s.edit("nest", |doc, active| { + let mut l = Layer::new("deep", LayerContent::Raster(Surface::new(PixelFormat::RGBA8))); + inner = Some(l.id); + for _ in 0..MAX_GROUP_DEPTH { + l = Layer::group("g", vec![l]); + } + doc.layers.push(l); + *active = inner; + Ok(()) + }) + .unwrap(); + s.set_active(0); + let before = doc(&s, 1).clone(); + let e = s.execute("layer.copyToDocument", json!({"document": 1, "layers": [group.0]})).unwrap_err(); + assert!(e.to_string().contains("deeper than"), "{e}"); + assert_eq!(*doc(&s, 1), before, "nothing was copied"); +} diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 9165990122..34a1f914ee 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -204,6 +204,7 @@ fn hide_selection(s: &mut Session, p: &Value) -> Result { mask.surface.write_region(bounds, &v); mask.surface.prune(); for id in ids { + crate::commands::check_all_unlocked(doc, id)?; crate::extra_cmds::background_to_layer_for_mask(doc, id); doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask.clone()); } @@ -219,6 +220,7 @@ fn mask_all_objects(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); + crate::commands::check_all_unlocked(&doc, id)?; let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; let region: Option = match layer.surface() { Some(surf) if matches!(layer.content, LayerContent::Raster(_)) => { @@ -421,7 +423,8 @@ fn reveal_command_on(os: &str, path: &str) -> Result<(&'static str, Vec) // Explorer splits `/select,` arguments at commas and reads quotes itself, so such a path // would select something else. "windows" if path.contains([',', '"']) => Err(other(format!("{path} can't be shown in Explorer: its path contains a comma or a quotation mark"))), - "windows" => Ok(("explorer", vec![format!("/select,{path}")])), + // Keep the switch outside the path's quotes: Explorer parses its own command line. + "windows" => Ok(("explorer", vec!["/select,".into(), path.replace('/', "\\")])), _ => { let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); Ok(("xdg-open", vec![parent])) diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 4f5c6dc12f..b0cceb72a8 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -541,12 +541,26 @@ fn reveal_follows_linked_paths_like_the_link_and_passes_paths_as_paths() { s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap(); assert_eq!(reveal_command_on("macos", "-a.png").unwrap(), ("open", vec!["-R".to_string(), "--".into(), "-a.png".into()])); - assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec![r"/select,C:\pics\a.png".to_string()])); + assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec!["/select,".to_string(), r"C:\pics\a.png".to_string()])); for path in [r"C:\pics\a,b.png", r#"C:\pics\a".png"#] { assert!(reveal_command_on("windows", path).is_err(), "{path}"); } } +/// Explorer parses `/select,` itself: it must remain outside the quoted file path. +#[test] +fn reveal_on_windows_keeps_the_select_switch_separate_from_the_path() { + for (path, expected) in [ + (r"C:\my pictures\a file.png", r"C:\my pictures\a file.png"), + ("C:/my pictures/a file.png", r"C:\my pictures\a file.png"), + (r"C:\my pictures/a file.png", r"C:\my pictures\a file.png"), + ("C:/项目 图片/海报 🖼.png", r"C:\项目 图片\海报 🖼.png"), + ("//server/share/my pictures/a file.png", r"\\server\share\my pictures\a file.png"), + ] { + assert_eq!(reveal_command_on("windows", path).unwrap(), ("explorer", vec!["/select,".to_string(), expected.to_string()]), "{path}"); + } +} + /// `xdg-open` launches a file it is given, so Reveal hands it the link's folder only when that is a /// folder: a link stored as `tool.jar/a.png` names a file, not a folder, as its parent. #[test] diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 83d4e815a2..47114f7e5d 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -118,6 +118,86 @@ pub(crate) fn recorded_selection(s: &Session, mut p: Value) -> Value { p } +/// The layers named `name`, in document order, other than the ones being moved and (groups) their +/// contents. Moving layers never changes the others' order, so an occurrence counted among these +/// means the same layer before the move (when an action replays it) and after it (when the step +/// is recorded). +fn same_named_targets(doc: &photocraft_doc::Document, name: &str, moved: &[LayerId]) -> Vec { + fn subtree(l: &Layer, out: &mut Vec, depth: usize) { + out.push(l.id); + if depth < 256 + && let Some(children) = l.children() + { + children.iter().for_each(|c| subtree(c, out, depth + 1)); + } + } + let mut skip = Vec::new(); + for l in moved.iter().filter_map(|id| doc.layer(*id)) { + subtree(l, &mut skip, 0); + } + doc.walk().into_iter().filter(|(_, _, l)| l.name == name && !skip.contains(&l.id)).map(|(_, _, l)| l.id).collect() +} + +/// A Layers-panel drag of the selected layer records its drop target by name (and which of +/// several same-named layers it is), as [`recorded_selection`] does: the id means nothing in the +/// document the action plays in later, so playback failed with "no such layer" (#7845). Drags of +/// several layers, or of a layer that isn't the selected one, keep their ids. +fn recorded_move(s: &Session, mut p: Value, pre_selected: Option<&[LayerId]>) -> Value { + if p.get("layers").is_some() { + return p; + } + let Some(st) = s.active() else { return p }; + let moved = match p.get("layer") { + None => st.active_layer, + Some(v) => match v.as_u64().map(LayerId) { + Some(id) if pre_selected.map_or_else(|| st.selected_layers().as_slice() == [id], |sel| sel == [id]) => Some(id), + _ => return p, + }, + }; + if let Some(fields) = p.as_object_mut() { + fields.remove("layer"); + } + let Some(id) = p.get("target").and_then(Value::as_u64).map(LayerId) else { return p }; + let Some(layer) = st.doc.layer(id) else { return p }; + // A copy leaves its source where it is (it may even land beside it), so the source stays a + // candidate; the new copy (the active layer once the step has run, which is when it is + // recorded) is left out instead, as it doesn't exist yet when the step replays. + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let skip = if copy { st.active_layer.filter(|a| Some(*a) != moved) } else { moved }; + let matches = same_named_targets(&st.doc, &layer.name, skip.as_slice()); + let Some(occurrence) = matches.iter().position(|candidate| *candidate == id) else { return p }; + if let Some(fields) = p.as_object_mut() { + fields.insert("target".into(), json!(layer.name)); + if matches.len() > 1 { + fields.insert("targetOccurrence".into(), json!(occurrence)); + } + } + p +} + +/// `layer.moveTo`'s drop target: an id, or a name with `targetOccurrence` choosing among layers +/// that share it (in document order, the `moved` layers aside; required when several do). +fn move_target(s: &Session, p: &Value, moved: &[LayerId]) -> Result { + const CMD: &str = "layer.moveTo"; + match p.get("target") { + Some(Value::String(name)) => { + let occurrence = p + .get("targetOccurrence") + .map(|v| v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, "`targetOccurrence` must be a non-negative integer"))) + .transpose()?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let matches = same_named_targets(&st.doc, name, moved); + if matches.len() > 1 && occurrence.is_none() { + return Err(bad(CMD, format!("multiple layers named `{name}`; specify `targetOccurrence`"))); + } + let index = occurrence.unwrap_or(0); + matches.get(index).copied().ok_or_else(|| bad(CMD, format!("no layer named `{name}` at occurrence {index}"))) + } + Some(v) => v.as_u64().map(LayerId).ok_or_else(|| bad(CMD, "`target` must be a layer id or name")), + None => Err(bad(CMD, "missing `target`")), + } +} + /// Normalise parameters when recording an action step. /// /// For `layer.select`, captures the layer by name. For other commands, if a numeric `layer` @@ -128,6 +208,9 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio if id == "layer.select" { return recorded_selection(s, p); } + if id == "layer.moveTo" { + return recorded_move(s, p, pre_selected); + } let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p }; let is_sole_selected = match pre_selected { Some(sel) => sel == [layer_id], @@ -1267,7 +1350,6 @@ fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: & /// selected nodes are detached, and every reference is validated before editing. pub fn move_to(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.moveTo"; - let target = LayerId(p.get("target").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "missing `target`"))?); let pos = p.get("position").and_then(Value::as_str).unwrap_or("above"); if !matches!(pos, "above" | "below" | "into") { return Err(bad(CMD, "position must be above, below, or into")); @@ -1293,6 +1375,8 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } else { vec![crate::commands::layer_param(s, p)?] }; + // A copy moves nothing, so its source stays a candidate (⌥-dragging a layer beside itself). + let target = move_target(s, p, if copy { &[] } else { &requested })?; let st = s.active().ok_or(EngineError::NoDocument)?; let doc = &st.doc; let target_path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?; diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 68e9344e59..2527f402f3 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -12,6 +12,8 @@ use crate::commands::{CommandSpec, blend_from_str}; use crate::presets::{always, bad}; use crate::{EngineError, Result, Session}; +mod gradient_overlay; + /// Parses a gradient style name (`linear`, `radial`, `angle`, `reflected`, `diamond`). pub fn gradient_style(s: &str) -> GradientStyle { match s.to_ascii_lowercase().as_str() { @@ -94,7 +96,7 @@ pub fn effect_defaults(kind: &str) -> Value { "stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "overprint": false, "color": "#000000"}), "colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}), "gradientOverlay" => { - json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100}) + json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100, "method": "custom"}) } "patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}), "bevelEmboss" => { @@ -524,7 +526,9 @@ fn fresh_effect(kind: &str) -> Option { overprint: false, }), "colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }, - "gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }, + "gradientOverlay" => { + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false, interpolation: None } + } "patternOverlay" => Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), name: String::new(), @@ -717,9 +721,9 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { common(c, p); set_color(p, "color", color); } - Effect::GradientOverlay { common: c, gradient: g, dither } => { + Effect::GradientOverlay { common: c, gradient: g, dither, interpolation } => { common(c, p); - overlay_gradient(g, p); + gradient_overlay::overlay(g, interpolation, p); if let Some(x) = b_of(p, "dither") { *dither = x; } @@ -1052,7 +1056,7 @@ pub fn specs() -> Vec { style_cmd!( "gradientOverlay", "Gradient Overlay…", - r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted on new effects: straight stops; edits retain the recorded method),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!( "patternOverlay", diff --git a/crates/engine/src/layer_style/gradient_overlay.rs b/crates/engine/src/layer_style/gradient_overlay.rs new file mode 100644 index 0000000000..8736597e08 --- /dev/null +++ b/crates/engine/src/layer_style/gradient_overlay.rs @@ -0,0 +1,246 @@ +//! Keep editable stops separate from the dense stops used by the compositors. + +use photocraft_doc::effects::GradientInterpolation; +use photocraft_doc::{Color, Gradient}; +use serde_json::Value; + +use super::{color_of, f_of, overlay_gradient}; + +fn edit_endpoint(stop: Option<&mut (f32, Color)>, params: &Value, key: &str) { + if let (Some(stop), Some(color)) = (stop, color_of(params, key)) { + // The dialog displays hex colours. Opening it must not quantize the saved source. + let [r, g, b, _] = color.to_rgba8(); + let [sr, sg, sb, _] = stop.1.to_rgba8(); + if !params.get(key).is_some_and(Value::is_string) || [r, g, b] != [sr, sg, sb] { + let mut edited = color.in_mode(stop.1.mode); + edited.alpha = stop.1.alpha; + stop.1 = edited; + } + } +} + +/// `params` without a `from`/`to` hex string that only repeats the gradient's current end as the +/// dialog shows it (8-bit): confirming the dialog unchanged must not quantize a baked or +/// imported curve's exact ends (as `edit_endpoint` guards the recorded source). +fn without_unchanged_ends(gradient: &Gradient, params: &Value) -> Value { + let mut params = params.clone(); + for (key, end) in [("from", gradient.stops.first()), ("to", gradient.stops.last())] { + let shown = |c: &Color| { + let [r, g, b, _] = c.to_rgba8(); + [r, g, b] + }; + let same = params.get(key).is_some_and(Value::is_string) && end.zip(color_of(¶ms, key)).is_some_and(|(end, color)| shown(&end.1) == shown(&color)); + if same && let Some(object) = params.as_object_mut() { + object.remove(key); + } + } + params +} + +/// Edits recorded source stops, initializing legacy curves from their end colours. +pub(super) fn overlay(gradient: &mut Gradient, interpolation: &mut Option, params: &Value) { + // Reset to Default restores the factory's unbaked interpolation too. + if params.get("method").and_then(Value::as_str) == Some("custom") { + if let Some(source) = interpolation.take() { + gradient.stops = source.stops; + } + overlay_gradient(gradient, &without_unchanged_ends(gradient, params)); + return; + } + let requested = params.get("method").and_then(Value::as_str).filter(|name| photocraft_io::GradientMethod::from_name(name).is_some()); + if interpolation.is_none() && requested.is_none() { + overlay_gradient(gradient, &without_unchanged_ends(gradient, params)); + return; + } + let previous = interpolation.clone(); + let source = interpolation.get_or_insert_with(|| GradientInterpolation { + method: requested.unwrap_or("classic").to_ascii_lowercase(), + smoothness: 100.0, + // Without a source record, intermediate stops may be baked samples (PSD import or + // older method commands). Keep the legacy rebuild-from-ends behaviour on first choice. + stops: vec![(0.0, gradient.stops.first().map_or(Color::BLACK, |s| s.1)), (1.0, gradient.stops.last().map_or(Color::WHITE, |s| s.1))], + }); + if let Some(method) = requested { + source.method = method.to_ascii_lowercase(); + } + if let Some(smoothness) = f_of(params, "smoothness").filter(|n| n.is_finite()) { + source.smoothness = smoothness.clamp(0.0, 100.0); + } + edit_endpoint(source.stops.first_mut(), params, "from"); + edit_endpoint(source.stops.last_mut(), params, "to"); + if previous.as_ref() != Some(source) + && let Some(method) = photocraft_io::GradientMethod::from_name(&source.method) + { + // Missing midpoint entries mean 50 %, including for multi-stop source gradients. + gradient.stops = photocraft_io::bake_gradient(source.stops.clone(), &[], source.smoothness / 100.0, method); + } + // Geometry edits must not replace just the ends of the baked curve. + let mut geometry = params.clone(); + if let Some(object) = geometry.as_object_mut() { + for key in ["method", "smoothness", "from", "to"] { + object.remove(key); + } + } + overlay_gradient(gradient, &geometry); +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::BlendMode; + use photocraft_doc::{Effect, FxCommon}; + use photocraft_io::{GradientMethod, bake_gradient}; + use serde_json::json; + + #[test] + fn choosing_a_method_on_a_baked_overlay_rebuilds_from_its_end_colours() { + let ends = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + for imported_method in [GradientMethod::Perceptual, GradientMethod::Smooth] { + let gradient = Gradient { + stops: bake_gradient(ends.clone(), &[0.5], 1.0, imported_method), + opacity_stops: vec![(0.0, 0.25), (0.6, 0.8), (1.0, 0.5)], + align: false, + offset: (0.2, -0.1), + ..Gradient::default() + }; + assert!(gradient.stops.len() > 2); + let source_ends = vec![(0.0, gradient.stops.first().unwrap().1), (1.0, gradient.stops.last().unwrap().1)]; + let mut fx = + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: gradient.clone(), dither: true, interpolation: None }; + super::super::overlay_effect(&mut fx, &json!({"method": "linear"})); + let mut expected = gradient; + expected.stops = bake_gradient(source_ends.clone(), &[0.5], 1.0, GradientMethod::Linear); + assert_eq!( + fx, + Effect::GradientOverlay { + common: FxCommon::new(BlendMode::Normal, 1.0), + gradient: expected, + dither: true, + interpolation: Some(GradientInterpolation { method: "linear".into(), smoothness: 100.0, stops: source_ends }), + } + ); + } + } + + #[test] + fn endpoint_edits_after_selecting_a_method_recolour_the_whole_baked_overlay() { + for same_edit in [false, true] { + let mut fx = Effect::GradientOverlay { + common: FxCommon::new(BlendMode::Normal, 1.0), + gradient: Gradient { + stops: bake_gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.5], 1.0, GradientMethod::Smooth), + ..Gradient::default() + }, + dither: false, + interpolation: None, + }; + if same_edit { + super::super::overlay_effect(&mut fx, &json!({"method": "classic", "from": "#ff0000", "to": "#0000ff"})); + } else { + super::super::overlay_effect(&mut fx, &json!({"method": "classic"})); + super::super::overlay_effect(&mut fx, &json!({"from": "#ff0000", "to": "#0000ff"})); + } + let ends = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))]; + let Effect::GradientOverlay { gradient, interpolation, .. } = fx else { panic!("expected overlay") }; + assert_eq!(gradient.stops, bake_gradient(ends.clone(), &[0.5], 1.0, GradientMethod::Classic)); + assert_eq!(interpolation.unwrap().stops, ends); + } + } + + #[test] + fn changing_method_keeps_all_source_stops_and_transparency() { + let mut gradient = Gradient { + stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.3, Color::rgb(0.0, 1.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], + opacity_stops: vec![(0.0, 0.25), (1.0, 0.8)], + align: false, + offset: (0.2, -0.1), + ..Gradient::default() + }; + let original = gradient.clone(); + let mut source = Some(GradientInterpolation { method: "classic".into(), smoothness: 100.0, stops: original.stops.clone() }); + gradient.stops = bake_gradient(original.stops.clone(), &[0.5, 0.5], 1.0, GradientMethod::Classic); + for method in ["classic", "smooth", "perceptual", "linear", "classic"] { + overlay(&mut gradient, &mut source, &json!({"method": method})); + let source = source.as_ref().unwrap(); + assert_eq!(source.method, method); + assert_eq!(source.stops, original.stops); + let expected = photocraft_io::bake_gradient(original.stops.clone(), &[0.5, 0.5], 1.0, photocraft_io::GradientMethod::from_name(method).unwrap()); + assert_eq!(gradient.stops, expected); + assert_eq!(gradient.opacity_stops, original.opacity_stops); + assert_eq!(gradient.align, original.align); + assert_eq!(gradient.offset, original.offset); + } + overlay(&mut gradient, &mut source, &json!({"from": "#112233", "to": "#445566"})); + let source = source.unwrap(); + assert_eq!(source.stops.len(), 3); + assert_eq!(source.stops[1], original.stops[1], "endpoint edits preserve the middle source stop"); + assert_eq!(gradient.stops, bake_gradient(source.stops, &[0.5, 0.5], 1.0, GradientMethod::Classic)); + } + + #[test] + fn colour_edit_rebakes_the_curve_and_geometry_edits_leave_it_exact() { + let mut gradient = Gradient::default(); + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"method": "smooth", "from": "#ff0000", "to": "#0000ff"})); + let before = gradient.stops.clone(); + overlay(&mut gradient, &mut source, &json!({"from": "#00ff00", "angle": 45})); + assert_ne!(gradient.stops, before); + let expected = photocraft_io::bake_gradient( + vec![(0.0, Color::rgb(0.0, 1.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], + &[0.5], + 1.0, + photocraft_io::GradientMethod::Smooth, + ); + assert_eq!(gradient.stops, expected); + overlay(&mut gradient, &mut source, &json!({"angle": 90, "reverse": true, "scale": 75})); + assert_eq!(gradient.stops, expected); + assert_eq!(gradient.angle, 90.0); + assert!(gradient.reverse); + assert_eq!(gradient.scale, 0.75); + } + + #[test] + fn reopening_does_not_quantize_source_colours_or_rebake() { + let mut gradient = Gradient::default(); + gradient.stops[0].1.alpha = 0.37; + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"from": [0.123456, 0.234567, 0.345678], "method": "perceptual"})); + let before = (gradient.clone(), source.clone()); + assert_eq!(source.as_ref().unwrap().stops[0].1.alpha, 0.37); + let [r, g, b, _] = source.as_ref().unwrap().stops[0].1.to_rgba8(); + overlay(&mut gradient, &mut source, &json!({"from": format!("#{r:02x}{g:02x}{b:02x}"), "to": "#ffffff", "method": "perceptual", "smoothness": 100})); + assert_eq!((gradient, source), before); + } + + #[test] + fn old_gradients_are_unchanged_until_a_method_is_chosen() { + let mut gradient = Gradient::default(); + gradient.stops.insert(1, (0.4, Color::rgb(0.1, 0.9, 0.2))); + let original = gradient.clone(); + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"method": "custom", "opacity": 50})); + assert_eq!(gradient, original); + assert!(source.is_none()); + } + + #[test] + fn endpoint_edits_keep_the_source_colour_model() { + for mode in [photocraft_doc::ColorMode::Grayscale, photocraft_doc::ColorMode::Cmyk, photocraft_doc::ColorMode::Lab] { + let mut gradient = Gradient { stops: vec![(0.0, Color::BLACK.in_mode(mode)), (1.0, Color::WHITE.in_mode(mode))], ..Gradient::default() }; + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"method": "classic"})); + overlay(&mut gradient, &mut source, &json!({"from": "#ff0000"})); + assert!(source.as_ref().unwrap().stops.iter().all(|s| s.1.mode == mode)); + } + } + + #[test] + fn reset_to_factory_defaults_removes_the_recorded_method() { + let mut gradient = Gradient::default(); + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"method": "smooth", "from": "#ff0000"})); + overlay(&mut gradient, &mut source, &super::super::effect_defaults("gradientOverlay")); + assert!(source.is_none()); + assert_eq!(gradient, Gradient::default()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 64814126cd..44cbe903ad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,6 +108,7 @@ pub mod retouch_cmds; pub mod sample_cmds; pub mod sampler_cmds; pub mod select_extra_cmds; +pub mod select_mask_edits; pub mod selection_cmds; pub mod shell_commands; pub mod shortcut_sets; @@ -117,17 +118,20 @@ pub mod smartselect_cmds; pub mod snap; pub mod solid_fill_cmds; pub mod stamp_cmds; +pub mod style_asl_cmds; pub mod swatch_cmds; pub mod symmetry_cmds; mod timeline_cmds; pub mod transform_cmds; mod trap_cmds; +pub mod type_area_cmds; pub mod type_caret_cmds; pub mod type_cmds; pub mod type_extra_cmds; pub mod type_mask_cmds; pub mod type_spell_cmds; pub mod type_styles_cmds; +pub mod updates_cmds; mod variables_cmds; pub use variables_cmds::{export_data_sets_with, import_data_sets_text}; pub mod vector_cmds; @@ -491,6 +495,8 @@ pub struct Session { color_restrict: Option, /// Edit › Preferences, keyboard shortcuts, menu and toolbar customisation (see `prefs`). pub prefs: prefs::PrefsStore, + /// Release checks use an injected platform transport; no network I/O in the engine. + pub updates: updates_cmds::Updates, /// Edit menu state: Fade source, custom shape library (see `edit_menu_cmds`). pub edit_state: edit_menu_cmds::EditState, /// The pattern library (Window › Patterns; see `pattern.*`). @@ -709,6 +715,17 @@ impl Session { Ok(()) } + /// [`Self::offer_live_stroke`] for a live Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke, + /// taken by the command it was drawn for: the stroke's finishing dabs are run first. Refused + /// when the brush, tool options or layer changed during the stroke. + pub fn offer_live_dab(&mut self, live: retouch_cmds::LiveDab) -> Result<()> { + // A refused offer withdraws the previous one too. + self.live_commit = None; + let commit = live.into_commit(self)?; + self.live_commit = Some(commit); + Ok(()) + } + /// Withdraw an offer the command didn't take. pub fn clear_live_stroke(&mut self) { self.live_commit = None; diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index dc4e531f53..01339e859d 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -58,20 +58,55 @@ pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::Pix photocraft_compose::composite_to_surface(solo, area, format, None) } +/// Most bytes of tiles an adjustment may materialize beyond the ones a surface stores: two distant +/// tiles make the content bounds a grid of millions of tiles that hold nothing. Written tiles are +/// copies, so the peak is about twice this. +const MAX_UNSTORED_BYTES: u64 = 1 << 30; + +/// Tiles in the grid that covers `r` (one extra per axis for a tile straddling its edge). +fn grid_tiles(r: Rect) -> u64 { + (u64::from(r.width()).div_ceil(TILE_SIZE as u64) + 1) * (u64::from(r.height()).div_ceil(TILE_SIZE as u64) + 1) +} + +/// Bytes the tiles of the grid over `r` take beyond the `stored` ones, in format `fmt`. +fn unstored_bytes(r: Rect, stored: u64, fmt: &photocraft_color::PixelFormat) -> u64 { + grid_tiles(r).saturating_sub(stored).saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64) +} + +/// The tile-sized pieces of `r`, each cut to `r`. +fn tile_rects(r: Rect) -> impl Iterator { + let t = i64::from(TILE_SIZE); + let cols = i64::from(r.x0).div_euclid(t)..=(i64::from(r.x1) - 1).div_euclid(t); + let rows = i64::from(r.y0).div_euclid(t)..=(i64::from(r.y1) - 1).div_euclid(t); + rows.flat_map(move |cy| cols.clone().map(move |cx| Rect::new((cx * t) as i32, (cy * t) as i32, ((cx + 1) * t) as i32, ((cy + 1) * t) as i32).intersect(&r))) +} + +const TOO_SPARSE: &str = "the adjustment would have to fill too large an area around the pixels the layer holds"; + /// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the /// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale). /// Works tile by tile in parallel, so a large layer needs one tile's buffers per thread rather /// than four full-layer copies, and the result is the same as one pass over the whole region. -pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) { +pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Result<(), String> { use rayon::prelude::*; let r = s.content_bounds(); if r.is_empty() { - return; + return Ok(()); } let fmt = s.format(); - let done: Vec<_> = s - .take_tiles(r) + // Two distant tiles make the content bounds a grid of millions of tiles that hold nothing. + // Past a budget a transparent default needs only the stored tiles adjusted (the gaps stay + // transparent); an opaque default would change under the adjustment everywhere in that grid, + // so the adjustment is refused rather than left half applied. + let stored: Vec<_> = s.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&r).is_empty()).collect(); + let transparent = fmt.alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0); + let over = unstored_bytes(r, stored.len() as u64, &fmt) > MAX_UNSTORED_BYTES; + if over && !transparent { + return Err(TOO_SPARSE.into()); + } + let taken = if over { stored.into_iter().flat_map(|c| s.take_tiles(c.rect())).collect() } else { s.take_tiles(r) }; + let done: Vec<_> = taken .into_par_iter() .flat_map_iter(|(tc, tile)| { // A one-tile surface, so `adjusted` reads and writes this tile only. @@ -84,6 +119,7 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf }) .collect(); s.put_tiles(done); + Ok(()) } #[cfg(test)] @@ -96,26 +132,46 @@ mod tests; /// Applies `adj` to a layer mask through the selection. Untouched mask pixels count (they read as /// the mask's default value), and without a selection the default changes too, since a mask /// reaches past the canvas: Invert turns a reveal-all mask into a hide-all one (#780). -pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) { +pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) -> Result<(), String> { let mode = photocraft_color::ColorMode::Grayscale; if let Some(sel) = selection { let r = sel.content_bounds(); - if !r.is_empty() { - let out = adjusted(s, r, adj, Some(sel), mode); - s.write_region(r, &out); + // Tile by tile, and only where the selection stores something (it is unselected, so + // unchanged, elsewhere): its content bounds can span a grid of mostly nothing. + let sparse = sel.default_pixel().iter().all(|v| *v == 0.0); + let tiles: Vec = if sparse { + sel.tiles().map(|(c, _)| c.rect().intersect(&r)).filter(|t| !t.is_empty()).collect() + } else if r.is_empty() { + Vec::new() + } else { + // Unselected pixels count too, so every tile of the span is adjusted, one at a time + // (not one read of the whole span), and the span is bounded by what that writes. + if unstored_bytes(r, 0, &s.format()) > MAX_UNSTORED_BYTES { + return Err(TOO_SPARSE.into()); + } + tile_rects(r).collect() + }; + for t in &tiles { + let out = adjusted(s, *t, adj, Some(sel), mode); + s.write_region(*t, &out); + } + if !tiles.is_empty() { s.prune(); } - return; + return Ok(()); } let fmt = s.format(); let unit = Rect::from_xywh(0, 0, 1, 1); let mut out = Surface::with_default(fmt, &adjusted(&Surface::with_default(fmt, &s.default_pixel()), unit, adj, None, mode)); - let r = s.tile_bounds(); - if !r.is_empty() { + // Everything the mask doesn't store reads as its default, which `out` already holds adjusted. + // So only the stored tiles are adjusted, not the (possibly huge) rectangle around them. + let stored: Vec = s.tiles().map(|(c, _)| c.rect()).collect(); + for r in stored { out.write_region(r, &adjusted(s, r, adj, None, mode)); } out.prune(); *s = out; + Ok(()) } /// `s`'s pixels over `r` (untouched ones included) with `adj` applied through the selection, encoded. diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index 5af30d0e76..10596fbca9 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -110,7 +110,7 @@ fn tiled_adjustment_matches_one_pass_over_the_content() { for sel in [None, Some(&soft)] { let want = one_pass(&s, &adj, sel, fmt.mode); let mut got = s.clone(); - adjust_surface(&mut got, &adj, sel, fmt.mode); + adjust_surface(&mut got, &adj, sel, fmt.mode).unwrap(); assert!(want != s, "{kind} {fmt:?}: the adjustment changes the layer"); assert!(got == want, "{kind} {fmt:?} selection {}: tiled result differs", sel.is_some()); } @@ -121,7 +121,7 @@ fn tiled_adjustment_matches_one_pass_over_the_content() { #[test] fn an_empty_layer_is_left_alone() { let mut s = Surface::new(PixelFormat::RGBA8); - adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb); + adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).unwrap(); assert_eq!(s.tile_count(), 0); } @@ -183,19 +183,29 @@ fn tiled_clear_and_fill_match_one_pass_over_the_area() { let sr = Rect::new(0, 0, 600, 400); soft.write_region(sr, &(0..sr.height()).flat_map(|_| (0..sr.width()).map(|x| (1.0 - (x as f32 - 256.0).max(0.0) / 300.0).max(0.0))).collect::>()); let canvas = Rect::new(0, 0, 700, 600); + // Pixel formats and fill settings 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; + let mut runs = || { + case += 1; + sweep.includes(case) + }; for fmt in formats { let s = clear_layer(fmt); for sel in [None, Some(&soft)] { let area = sel.map_or(canvas, |m| m.content_bounds()); let what = format!("{fmt:?} selection {}", sel.is_some()); - let mut got = s.clone(); - try_clear_surface(&mut got, area, sel).unwrap(); - assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, None)), "clear {what}"); - if fmt.alpha { - assert!(!same_pixels(&got, &s), "clear {what} changes the layer"); + if runs() { + let mut got = s.clone(); + try_clear_surface(&mut got, area, sel).unwrap(); + assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, None)), "clear {what}"); + if fmt.alpha { + assert!(!same_pixels(&got, &s), "clear {what} changes the layer"); + } } for color in [[1.0, 1.0, 1.0, 1.0], [0.3, 0.5, 0.9, 1.0], [0.3, 0.5, 0.9, 0.4]] { - for lock in [true, false] { + for lock in [true, false].into_iter().filter(|_| runs()) { let mut got = s.clone(); try_fill_surface(&mut got, area, color, sel, lock).unwrap(); assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, Some((color, lock)))), "fill {color:?} lock {lock} {what}"); @@ -253,7 +263,7 @@ fn invert_in_a_lab_document_mirrors_l_a_and_b() { let mut s = Surface::new(fmt); let r = Rect::new(0, 0, 2, 2); s.write_region(r, &(0..4).flat_map(|_| from_rgba(&fmt, [c[0], c[1], c[2], 1.0])).collect::>()); - adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Lab); + adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Lab).unwrap(); let px = s.read_region(Rect::new(1, 1, 2, 2)); let rgba = photocraft_raster::to_rgba(&fmt, &px); let got = srgb_to_lab([rgba[0], rgba[1], rgba[2]]); @@ -308,7 +318,7 @@ fn adjusted_pixel(fmt: PixelFormat, ch: &[f32], adj: &Adjustment, k: Option if let Some(k) = k { sel.write_region(unit, &[k]); } - adjust_surface(&mut s, adj, k.map(|_| &sel), fmt.mode); + adjust_surface(&mut s, adj, k.map(|_| &sel), fmt.mode).unwrap(); s.read_region(unit) } @@ -394,3 +404,52 @@ fn hue_saturation_and_color_balance_keep_a_cmyk_layers_black() { assert!((0..4).all(|c| (half[c] - (ink[c] + (want[0][c] - ink[c]) * k)).abs() < 1e-3), "{}: half a selection", adj.label()); } } + +/// Two small blocks two billion pixels apart: the content bounds are a grid of 6·10¹³ tiles +/// that holds nothing, which no adjustment may materialize. +fn distant_blocks(fmt: PixelFormat, default: f32) -> Surface { + let mut s = Surface::with_default(fmt, &vec![default; fmt.channels()]); + s.fill_rect(Rect::new(0, 0, 10, 10), &vec![0.25; fmt.channels()]); + s.fill_rect(Rect::new(2_000_000_000, 2_000_000_000, 2_000_000_010, 2_000_000_010), &vec![0.25; fmt.channels()]); + s +} + +#[test] +fn adjusting_a_layer_of_distant_blocks_visits_only_its_tiles() { + let mut s = distant_blocks(PixelFormat::RGBA8, 0.0); + adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).unwrap(); + let p = s.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006)); + // Straight colour inverted, alpha untouched. + assert!((p[0] - 0.75).abs() < 0.01 && (p[3] - 0.25).abs() < 0.01, "{p:?}"); + assert_eq!(s.tile_count(), 2); +} + +#[test] +fn adjusting_a_mask_of_distant_blocks_visits_only_its_tiles() { + let mut m = distant_blocks(PixelFormat::GRAY8, 1.0); + adjust_mask(&mut m, &Adjustment::Invert, None).unwrap(); + assert!(m.default_pixel()[0] < 0.01, "the default is inverted: {:?}", m.default_pixel()); + let p = m.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006)); + assert!((p[0] - 0.75).abs() < 0.01, "{p:?}"); + // Through a selection of its own distant tiles, too. + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(2_000_000_000, 2_000_000_000, 2_000_000_010, 2_000_000_010), &[1.0]); + adjust_mask(&mut m, &Adjustment::Invert, Some(&sel)).unwrap(); + let p = m.read_region(Rect::new(2_000_000_005, 2_000_000_005, 2_000_000_006, 2_000_000_006)); + assert!((p[0] - 0.25).abs() < 0.01, "{p:?}"); +} + +#[test] +fn an_opaque_layer_of_distant_blocks_is_refused_not_half_adjusted() { + let mut s = distant_blocks(PixelFormat::RGBA8, 1.0); + let before = s.clone(); + assert!(adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb).is_err()); + assert!(s == before, "a refused adjustment leaves the layer as it was"); +} + +#[test] +fn a_selection_with_a_nonzero_default_over_distant_blocks_is_refused() { + let mut m = Surface::with_default(PixelFormat::GRAY8, &[0.5]); + let sel = distant_blocks(PixelFormat::GRAY8, 1.0); + assert!(adjust_mask(&mut m, &Adjustment::Invert, Some(&sel)).is_err()); +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a34bda68e9..6b7639b3ae 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -227,6 +227,8 @@ pub struct General { pub zoom_resizes_windows: bool, pub use_legacy_free_transform: bool, pub auto_show_home_screen: bool, + /// Desktop: check published stable releases once in the background at launch. + pub automatically_check_updates: bool, } impl Default for General { @@ -243,6 +245,7 @@ impl Default for General { zoom_resizes_windows: false, use_legacy_free_transform: false, auto_show_home_screen: true, + automatically_check_updates: true, } } } @@ -1124,7 +1127,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "unitsAndRulers.gutter", "plugIns.showExtensionPanels", "plugIns.allowScriptsToConnect", - "plugIns.generatorEnabled", "type.textEngine", "enhancedControls.scrubbySliderAcceleration", "rawDefaults.colorSpace", @@ -1715,6 +1717,10 @@ impl PrefsStore { } } +/// The preferences file format written by [`Session::prefs_value`]. 2: Use Shift Key for Tool +/// Switch stored as chosen (before, a stored `false` was the old default; see `load_prefs_json`). +pub const PREFS_VERSION: u64 = 2; + /// A saved preference [`Session::load_prefs_json`] couldn't read (a wrong type, an unknown /// choice) and left at its default. #[derive(Clone, Debug, PartialEq)] @@ -1850,7 +1856,7 @@ impl Session { let mut v = self.prefs().to_json(); if let Value::Object(m) = &mut v { m.insert("colorSettings".into(), serde_json::to_value(&self.color.settings).unwrap_or(Value::Null)); - m.insert("version".into(), json!(1)); + m.insert("version".into(), json!(PREFS_VERSION)); m.insert("presets".into(), self.presets.to_json(self)); } v @@ -1866,6 +1872,16 @@ impl Session { if !v.is_object() { return Err("preferences: expected a JSON object".into()); } + // Files before version 2 hold Use Shift Key for Tool Switch off because that was the + // default when they were written: every key is saved, chosen or not. Like Photoshop it is + // on by default now (#3528), so those files take the new default (#5647: a tool letter + // pressed again kept cycling its group). A choice made since is saved as version 2. + if v.get("version").and_then(Value::as_u64).unwrap_or(0) < 2 + && let Some(tools) = v.get_mut("tools").and_then(Value::as_object_mut) + && tools.get("useShiftKeyForToolSwitch") == Some(&json!(false)) + { + tools.remove("useShiftKeyForToolSwitch"); + } // Saved preferences before appearance modes had one concrete theme. Preserve its // appearance instead of silently switching established users to Auto. if let Some(interface) = v.get_mut("interface").and_then(Value::as_object_mut) @@ -1913,7 +1929,11 @@ impl Session { /// Batches defer colour validation until their complete candidate is staged. Returns /// whether colour settings were touched, including whole-section/root-object updates. fn set_pref(&mut self, path: &str, value: Value, validate_color: bool) -> std::result::Result { - if let Some(rest) = path.strip_prefix("colorSettings") { + // The section itself or a path inside it: `colorSettingsworkingRgb`, `colorSettings.` and + // `colorSettings..workingRgb` are none, and the authorization policy doesn't treat them as one. + if let Some(rest) = + path.strip_prefix("colorSettings").filter(|r| r.is_empty() || r.strip_prefix('.').is_some_and(|c| c.split('.').all(|part| !part.is_empty()))) + { let mut cur = serde_json::to_value(&self.color.settings).map_err(|e| e.to_string())?; let rest = rest.trim_start_matches('.'); if rest.is_empty() { @@ -2719,3 +2739,19 @@ pub fn specs() -> Vec { #[cfg(test)] #[path = "prefs/tests.rs"] mod tests; + +#[cfg(test)] +mod color_settings_path_tests { + use crate::Session; + use serde_json::json; + + #[test] + fn a_path_that_only_starts_with_color_settings_is_not_in_that_section() { + let mut s = Session::new(); + let before = s.color.settings.clone(); + for path in ["colorSettingsworkingRgb", "colorSettings.", "colorSettings..workingRgb"] { + assert!(s.execute("prefs.set", json!({"path": path, "value": "/dev/zero"})).is_err(), "{path}"); + } + assert_eq!(s.color.settings, before); + } +} diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 6f90daf9d2..8ce36173b7 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -861,6 +861,29 @@ fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() { assert!(!stored.use_shift_key_for_tool_switch); } +/// #5647: a settings file from before version 2 stored the switch off only because that was the +/// old default, so it loads with the new default (on); one saved since keeps an explicit off. +#[test] +fn old_settings_files_take_the_shift_key_tool_switch_default() { + for old in + [json!({"tools": {"useShiftKeyForToolSwitch": false}}), json!({"version": 1, "tools": {"useShiftKeyForToolSwitch": false, "showTooltips": false}})] + { + let mut s = Session::new(); + s.load_prefs_json(&old.to_string()).unwrap(); + assert!(s.prefs().tools.use_shift_key_for_tool_switch, "{old}"); + } + let mut s = Session::new(); + s.load_prefs_json(&json!({"version": 1, "tools": {"showTooltips": false}}).to_string()).unwrap(); + assert!(!s.prefs().tools.show_tooltips, "other tool settings are kept"); + // Chosen off and saved now: kept on the next start. + s.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); + let saved = s.prefs_to_json(); + assert_eq!(serde_json::from_str::(&saved).unwrap()["version"], json!(PREFS_VERSION)); + let mut restored = Session::new(); + restored.load_prefs_json(&saved).unwrap(); + assert!(!restored.prefs().tools.use_shift_key_for_tool_switch); +} + /// Shortcut exports are all or nothing (#5801): a rename that fails after the first file was /// moved in puts the user's files back as they were and leaves nothing staged. #[test] diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 14273c2bd9..417f5623a1 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -146,11 +146,20 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io Ok(json!({ "group": group, "imported": names, "count": names.len(), "folders": folders, "version": imp.version, "warnings": imp.warnings })) } +/// Largest canvas (268 MP) a brush may be made from. +const MAX_BRUSH_SOURCE_PIXELS: u64 = 1 << 28; + /// The image's paint amount (darkness × alpha of its composite, as Define Brush Preset reads a /// selection), cropped to the painted extent. The composite is rendered in bands twice: once to /// find the painted bounds, then only those, so a large mostly-white image never needs a /// full-size buffer. fn image_paint(doc: &photocraft_doc::Document, cmd: &str) -> Result<(Vec, usize, usize)> { + // The whole canvas is rendered (twice) to find the painted part: a sparse native file declares + // its canvas, so refuse one far past any brush source before rendering it. + let canvas = doc.bounds(); + if u64::from(canvas.width()) * u64::from(canvas.height()) > MAX_BRUSH_SOURCE_PIXELS { + return Err(bad(cmd, format!("the image's {}×{} canvas is too large to make a brush from", canvas.width(), canvas.height()))); + } let paint = |c: [f32; 4]| ((1.0 - photocraft_color::blend::lum([c[0], c[1], c[2]])) * c[3]).clamp(0.0, 1.0); let mut bounds: Option = None; let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), ()> { diff --git a/crates/engine/src/presets/styles.rs b/crates/engine/src/presets/styles.rs index ee4607cf86..eb7cc17be0 100644 --- a/crates/engine/src/presets/styles.rs +++ b/crates/engine/src/presets/styles.rs @@ -25,6 +25,9 @@ pub struct StylePreset { pub blend: Option, #[serde(default)] pub fill_opacity: Option, + /// Layer opacity saved with the blending options (0..1). + #[serde(default)] + pub opacity: Option, } impl Named for StylePreset { @@ -43,7 +46,7 @@ impl StylePreset { .as_array() .map(|a| a.iter().filter_map(|e| effect_from_params(e.get(0)?.as_str()?, e.get(1).unwrap_or(&Value::Null))).collect()) .unwrap_or_default(); - StylePreset { name: name.into(), effects, blend: None, fill_opacity: fill } + StylePreset { name: name.into(), effects, blend: None, fill_opacity: fill, opacity: None } } pub fn to_json(&self) -> Value { @@ -52,6 +55,7 @@ impl StylePreset { "effects": self.effects.iter().map(|e| e.label()).collect::>(), "blend": self.blend.map(|b| b.label()), "fillOpacity": self.fill_opacity.map(|f| (f * 100.0).round()), + "opacity": self.opacity.map(|f| (f * 100.0).round()), }) } } @@ -182,6 +186,12 @@ fn apply(s: &mut Session, p: &Value) -> Result { effects.push(fx); pats.extend(pat); } + // Pattern strokes and bevel textures name their pattern too: copy it when the library has it. + for (name, id) in photocraft_io::asl_map::referenced_patterns(&effects) { + if let Some(pat) = crate::pattern_cmds::resolve(s, if id.is_empty() { &name } else { &id }).filter(|_| !(id.is_empty() && name.is_empty())) { + pats.push(pat); + } + } let label = format!("Apply Style: {}", style.name); s.edit(&label, |doc, _| { for pat in &pats { @@ -202,7 +212,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { } l.effects.psd_raw = None; l.effects.enabled = true; - if !add || style.blend.is_some() { + // Adding keeps the layer's blending unless the preset carries some: a blend mode, or + // an Opacity saved on its own (`style.presets.new` with `includeBlending: false`). + if !add || style.blend.is_some() || style.opacity.is_some() { if let Some(b) = style.blend { l.blend = b; } @@ -211,6 +223,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { } else if !add { l.fill_opacity = 1.0; } + if let Some(o) = style.opacity { + l.opacity = o; + } } } Ok(()) @@ -256,6 +271,7 @@ fn new_preset(s: &mut Session, p: &Value) -> Result { }, blend: p.get("blend").and_then(Value::as_str).and_then(crate::commands::blend_from_str).or_else(|| with_blend.then_some(l.blend)), fill_opacity: p.get("fillOpacity").and_then(Value::as_f64).map(|v| (v / 100.0).clamp(0.0, 1.0) as f32).or_else(|| with_blend.then_some(l.fill_opacity)), + opacity: p.get("opacity").and_then(Value::as_f64).map(|v| (v / 100.0).clamp(0.0, 1.0) as f32).or_else(|| with_blend.then_some(l.opacity)), }; let gi = group_index(&mut s.presets.styles, str_param(p, "group")); let out = json!({"name": style.name, "group": s.presets.styles[gi].name, "effects": style.effects.len()}); @@ -336,7 +352,7 @@ pub fn specs() -> Vec { label: "Style Presets", menu: &[], shortcut: None, - params: "{} → {groups:[{name,presets:[{name,effects,blend,fillOpacity}]}]}", + params: "{} → {groups:[{name,presets:[{name,effects,blend,fillOpacity,opacity}]}]}", enabled: always, run: list, journal: false, @@ -356,7 +372,7 @@ pub fn specs() -> Vec { label: "New Style…", menu: &[], shortcut: None, - params: r##"{"name":str="Style","group":name?,"layer":id?,"includeEffects":bool=true,"includeBlending":bool=true,"effects":[[kind, params]]? (explicit list, e.g. the Layer Style dialog's pending state; overrides includeEffects),"blend":str?,"fillOpacity":0..100? (override the layer's blending options)}"##, + params: r##"{"name":str="Style","group":name?,"layer":id?,"includeEffects":bool=true,"includeBlending":bool=true,"effects":[[kind, params]]? (explicit list, e.g. the Layer Style dialog's pending state; overrides includeEffects),"blend":str?,"fillOpacity":0..100?,"opacity":0..100? (override the layer's blending options)}"##, enabled: has_layer, run: new_preset, journal: true, diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index 89ba8a5855..b196117839 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -477,6 +477,25 @@ fn styles_apply_to_selected_layers_and_new_from_layer() { assert!(px[(24 * 48 + 24) * 4 + 3] == 255); } +/// A preset saved without blending options but with an explicit Opacity carries only that +/// opacity: ⇧-click (`add`) sets it and leaves the layer's blend mode and Fill alone. +#[test] +fn an_opacity_only_style_added_sets_just_the_opacity() { + let mut s = session(8); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().map(LayerId).unwrap_or_else(|| active(&s)); + s.execute("style.presets.new", json!({"name": "Faded", "includeBlending": false, "effects": [["stroke", {"size": 2}]], "opacity": 40})).unwrap(); + s.edit("Blending fixture", |doc, _| { + let l = doc.layer_mut(a).unwrap(); + (l.blend, l.fill_opacity, l.opacity) = (photocraft_doc::BlendMode::Multiply, 0.7, 0.9); + Ok(()) + }) + .unwrap(); + s.execute("style.presets.apply", json!({"preset": "Faded", "add": true, "layer": a.0})).unwrap(); + let l = s.active().unwrap().doc.layer(a).unwrap().clone(); + assert_eq!((l.blend, l.fill_opacity, l.opacity), (photocraft_doc::BlendMode::Multiply, 0.7, 0.4)); + assert_eq!(l.effects.items.len(), 1); +} + #[test] fn styles_with_pattern_overlay_copy_the_pattern() { let mut s = session(8); @@ -487,6 +506,7 @@ fn styles_with_pattern_overlay_copy_the_pattern() { effects: vec![crate::layer_style::effect_from_params("patternOverlay", &json!({"pattern": "Bricks"})).unwrap()], blend: None, fill_opacity: None, + opacity: None, }); s.execute("style.presets.apply", json!({"preset": "Bricky", "layer": id.0})).unwrap(); assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.name == "Bricks")); diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index a0395ff520..71efe29735 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -1156,13 +1156,14 @@ fn package(s: &mut Session, p: &Value) -> Result { missing.push(path.clone()); continue; }; - match read_file(&file.to_string_lossy()) { - Ok(bytes) => { + // Only a file PhotoCraft can place is copied; anything else is packaged as missing. + match read_file(&file.to_string_lossy()).ok().filter(|bytes| crate::file_cmds::import(&fname, bytes).is_ok()) { + Some(bytes) => { write_file(&t, &bytes)?; copied.push((path.clone(), t.clone())); t } - Err(_) => { + None => { missing.push(path.clone()); continue; } diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 005377328f..eae429f8c0 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -981,7 +981,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result { /// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and /// turned into its per-dab effect, for the commit and the live preview alike. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] enum DabTool { Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool }, Sponge { saturate: bool, vibrance: bool }, @@ -992,6 +992,37 @@ enum DabTool { /// One dab's edit of the working copy. pub(crate) type DabEffect = Box; +/// Pixels an effect reads beyond the dab it writes. Smudge's pickup is a fixed-size square, +/// independent of pressure, roundness and the dab's current angle. +#[derive(Clone, Copy)] +enum DabNeighborhood { + Halo(i32), + Smudge(i32), +} + +impl DabNeighborhood { + fn halo(self) -> i32 { + match self { + Self::Halo(radius) | Self::Smudge(radius) => radius, + } + } + + fn read_rect(self, rect: Rect, dab: &photocraft_paint::Dab) -> Rect { + match self { + Self::Halo(radius) => rect.inflate(radius), + Self::Smudge(radius) => { + let (x, y) = (dab.center.x.floor() as i32, dab.center.y.floor() as i32); + rect.union(&Rect::new( + x.saturating_sub(radius), + y.saturating_sub(radius), + x.saturating_add(radius).saturating_add(1), + y.saturating_add(radius).saturating_add(1), + )) + } + } + } +} + impl DabTool { /// The tool for `cmd` and its history label. fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { @@ -1033,10 +1064,11 @@ impl DabTool { } /// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood). - fn halo(&self) -> i32 { + fn neighborhood(&self) -> DabNeighborhood { match self { - Self::Focus { sigma, .. } => (sigma * 3.0).ceil() as i32 + 1, - _ => 0, + Self::Focus { sigma, .. } => DabNeighborhood::Halo((sigma * 3.0).ceil() as i32 + 1), + Self::Smudge { max_size, .. } => DabNeighborhood::Smudge(Smudge::sampling_radius(*max_size)), + _ => DabNeighborhood::Halo(0), } } @@ -1096,67 +1128,110 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { let (stroke, id) = parse_brush(s, p, cmd)?; let (tool, label) = DabTool::parse(s, cmd, p, &stroke)?; let all = matches!(tool, DabTool::Focus { .. } | DabTool::Smudge { .. }) && sample_all_layers(p); + // The live stroke the canvas already drew for exactly this commit (`LiveDab::into_commit`): + // take its pixels instead of running every dab again (the hitch on release). + let live = s.live_commit.take().filter(|l| l.matches(s, cmd, p, &stroke.points)); + let from_live = live.is_some(); let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } let fmt = surf.format(); let mut effect = tool.effect(fmt, stroke.brush.spacing); let mut sampler = all.then(|| AllLayers::new(pre, fmt)); - Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.halo(), |work, fp| match &mut sampler { + Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), None => effect(work, fp), })) })?; - Ok(json!({ "damage": damage_json(dmg) })) + let mut r = json!({ "damage": damage_json(dmg) }); + if from_live { + r["fromLive"] = json!(true); + } + Ok(r) } -/// The working pixels of a [`LiveDab`]: a copy of the target in its own depth, or (`precise`) -/// float pixels copied from it a tile at a time as dabs first reach them, so only the stroke's -/// area is ever copied. -struct WorkPixels { - surf: Surface, - /// Tiles (`TILE` px) copied from the target so far; `None` when `surf` is a full copy. - filled: Option>, +/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]). +const WORK_TILE: i32 = 128; + +/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in +/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a +/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the +/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels. +struct WorkTiles { + ch: usize, + tiles: std::collections::HashMap<(i32, i32), Vec>, } -impl WorkPixels { - const TILE: i32 = 64; - - fn new(pre: &Surface, precise: bool) -> Self { - if precise { - let f = pre.format(); - let fmt = PixelFormat::new(f.mode, SampleType::F32, f.alpha); - Self { surf: Surface::new(fmt), filled: Some(std::collections::HashSet::new()) } - } else { - Self { surf: pre.clone(), filled: None } - } +impl WorkTiles { + fn new(ch: usize) -> Self { + Self { ch, tiles: std::collections::HashMap::new() } } - /// The working pixels of `r` (`pre` = the target before the stroke). - fn read(&mut self, pre: &Surface, r: Rect) -> Region { - if let Some(filled) = &mut self.filled - && !r.is_empty() - { - let t = Self::TILE; - for ty in r.y0.div_euclid(t)..=(r.y1 - 1).div_euclid(t) { - for tx in r.x0.div_euclid(t)..=(r.x1 - 1).div_euclid(t) { - if filled.insert((tx, ty)) { - let cell = Rect::new(tx * t, ty * t, tx * t + t, ty * t + t); - // Normalised samples, stored unrounded as floats. - self.surf.write_region(cell, &pre.read_region(cell)); - } + fn tile_rect((tx, ty): (i32, i32)) -> Rect { + Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) + } + + /// The tiles `r` overlaps. + fn coords(r: Rect) -> impl Iterator { + let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); + let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); + let empty = r.is_empty(); + (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) + } + + /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. + fn read(&self, pre: &Surface, r: Rect) -> Region { + let mut out = Region::new(r, self.ch); + let n = self.ch; + let mut buf = Vec::new(); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + let tile = self.tiles.get(&c); + if tile.is_none() { + pre.read_region_into(part, &mut buf); + } + for y in part.y0..part.y1 { + let src = match tile { + Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), + None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), + }; + let dst = out.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { + d.copy_from_slice(s); } } } - Region::read(&self.surf, r) + out } - fn write(&mut self, region: &Region) { - region.write(&mut self.surf); + /// Store `region` as the working pixels of its rectangle. + fn write(&mut self, pre: &Surface, region: &Region) { + let (r, n) = (region.rect, self.ch); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + for y in part.y0..part.y1 { + let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; + let src = region.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } } } /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. +/// The working copy keeps full precision ([`WorkTiles`]), so the pixels are the commit's, and the +/// commit takes them over ([`Session::offer_live_dab`]) instead of running every dab again. /// Sample All Layers isn't previewed (its composite spans the whole stroke). pub struct LiveDab { /// The active document with the stroke so far. @@ -1165,9 +1240,9 @@ pub struct LiveDab { renderer: photocraft_paint::StrokeRenderer, done: usize, effect: DabEffect, - halo: i32, + neighborhood: DabNeighborhood, pre_surf: Surface, - work: WorkPixels, + work: WorkTiles, id: Option, params: Value, opacity: f32, @@ -1181,6 +1256,15 @@ pub struct LiveDab { preview_tail: bool, /// Where the shown tail is. tail: Rect, + /// The tool and brush the stroke started with, which its commit must still have: the + /// document revision doesn't cover the session's brush or foreground colour. `None` for + /// strokes whose commit renders itself (Color Replacement). + tool: Option<(DabTool, Box)>, + /// The document and revision the stroke started on, for strokes started from the session's + /// active document ([`Self::with_effect`]); `None` for strokes on a prepared copy. + base: Option<(photocraft_doc::DocId, u64)>, + /// Every point rendered so far, in order. + points: Vec, } impl LiveDab { @@ -1192,8 +1276,11 @@ impl LiveDab { } let (stroke, id) = parse_brush(s, p, cmd)?; let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; - let (halo, spacing) = (tool.halo(), stroke.brush.spacing); - Self::with_effect(s, cmd, p, stroke, id, halo, |pre| tool.effect(pre.format(), spacing)) + let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); + let effect = tool.clone(); + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?; + live.tool = Some((tool, brush)); + Ok(live) } /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) @@ -1207,23 +1294,47 @@ impl LiveDab { halo: i32, effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { - let doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); - Self::with_effect_on(doc, cmd, p, stroke, id, halo, false, effect) + Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) + } + + fn with_neighborhood( + s: &Session, + cmd: &str, + p: &Value, + stroke: Stroke, + id: Option, + neighborhood: DabNeighborhood, + effect: impl FnOnce(&Surface) -> DabEffect, + ) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let base = (st.doc.id, st.revision); + let doc = (*st.doc).clone(); + let mut live = Self::with_neighborhood_on(doc, cmd, p, stroke, id, neighborhood, effect)?; + live.base = Some(base); + Ok(live) } /// [`Self::with_effect`] on `doc`, a copy of the active document with what the commit's own - /// edit does first (the Background Eraser's Background turned into a normal layer, say). With - /// `precise` the working pixels between dabs are kept as floats, as the commit keeps them, so - /// the preview is the commit at any depth (else they are stored in the target's depth). - #[allow(clippy::too_many_arguments)] + /// edit does first (the Background Eraser's Background turned into a normal layer, say). pub(crate) fn with_effect_on( - mut doc: Document, + doc: Document, cmd: &str, p: &Value, stroke: Stroke, id: Option, halo: i32, - precise: bool, + effect: impl FnOnce(&Surface) -> DabEffect, + ) -> Result { + Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) + } + + fn with_neighborhood_on( + mut doc: Document, + cmd: &str, + p: &Value, + stroke: Stroke, + id: Option, + neighborhood: DabNeighborhood, effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { let sel = doc.selection.clone(); @@ -1238,8 +1349,8 @@ impl LiveDab { renderer, done: 0, effect, - halo, - work: WorkPixels::new(&pre_surf, precise), + neighborhood, + work: WorkTiles::new(pre_surf.channels()), pre_surf, id, params: p.clone(), @@ -1249,6 +1360,9 @@ impl LiveDab { bounds: Rect::EMPTY, preview_tail: false, tail: Rect::EMPTY, + tool: None, + base: None, + points: Vec::new(), }; live.push(&stroke.points)?; Ok(live) @@ -1266,11 +1380,9 @@ impl LiveDab { self.bounds } - /// Render more points; returns the rectangle that changed. - /// Invalid coordinates reject the whole batch without changing the preview. - pub fn push(&mut self, pts: &[StrokePoint]) -> Result { - crate::brush_cmds::check_coords(pts, &self.cmd)?; - self.renderer.push(pts); + /// Run the dabs the renderer has generated since the last call on the working copy; returns + /// the rectangle they changed. + fn run_new_dabs(&mut self) -> Rect { let ctx = self.renderer.ctx.clone(); let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec(); let mut dmg = Rect::EMPTY; @@ -1279,19 +1391,39 @@ impl LiveDab { if r.is_empty() { continue; } - let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); - self.work.write(®ion.crop(r)); + self.work.write(&self.pre_surf, ®ion.crop(r)); dmg = dmg.union(&r); } self.done += new.len(); + dmg + } + + /// Mix the working pixels of `r` back over the original into the shown document. + fn show(&mut self, r: Rect, work: &Region) -> Result<()> { + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), work, r, self.opacity, self.sel.as_ref(), self.lock); + Ok(()) + } + + /// Render more points; returns the rectangle that changed. + /// Invalid coordinates reject the whole batch without changing the preview. + /// Only the position and pressure of `pts` are used: the commands take no tilt or times. + pub fn push(&mut self, pts: &[StrokePoint]) -> Result { + crate::brush_cmds::check_coords(pts, &self.cmd)?; + let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); + self.points.extend_from_slice(&pts); + self.renderer.push(&pts); + let dmg = self.run_new_dabs(); + let ctx = self.renderer.ctx.clone(); // The finishing dabs, run on a scratch copy of the working pixels. let mut tail = None; if self.preview_tail { let ds = self.renderer.tail_dabs(); let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); if !r.is_empty() { - let mut region = self.work.read(&self.pre_surf, r.inflate(self.halo)); + let mut region = self.work.read(&self.pre_surf, r.inflate(self.neighborhood.halo())); for (k, d) in ds.iter().enumerate() { (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); } @@ -1306,17 +1438,42 @@ impl LiveDab { return Ok(Rect::EMPTY); } self.bounds = self.bounds.union(&dmg).union(&tail_rect); - let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; if !shown.is_empty() { - let (orig, work) = (Region::read(&self.pre_surf, shown), self.work.read(&self.pre_surf, shown)); - photocraft_paint::retouch::mix_back(surf, &orig, &work, shown, self.opacity, self.sel.as_ref(), self.lock); + let work = self.work.read(&self.pre_surf, shown); + self.show(shown, &work)?; } if let Some((r, work)) = tail { - photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), &work, r, self.opacity, self.sel.as_ref(), self.lock); + self.show(r, &work)?; self.tail = r; } Ok(shown.union(&tail_rect)) } + + /// The finished stroke as its commit would take it over ([`Session::offer_live_dab`]): the + /// finishing dabs are run, and the pixels are the ones the command with the same params and + /// points paints. Only while the brush, tool settings and layer are still the ones the stroke + /// started with: the document revision doesn't cover those. + pub(crate) fn into_commit(mut self, s: &Session) -> Result { + let (Some((tool, brush)), Some(base)) = (self.tool.take(), self.base) else { + return Err(bad(&self.cmd, "this stroke's commit renders itself")); + }; + let (stroke, id) = parse_brush(s, &self.params, &self.cmd)?; + let (now, _) = DabTool::parse(s, &self.cmd, &self.params, &stroke)?; + if now != tool || stroke.brush != *brush || id != self.id { + return Err(bad(&self.cmd, "the brush, tool options or layer changed during the stroke")); + } + self.renderer.finish(); + let dmg = self.run_new_dabs(); + let shown = dmg.union(&self.tail); + if !shown.is_empty() { + let work = self.work.read(&self.pre_surf, shown); + self.show(shown, &work)?; + } + self.bounds = self.bounds.union(&dmg); + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + let surface = surf.clone(); + Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base, params: self.params, seed: None, points: self.points, surface, bounds: self.bounds }) + } } /// "Sample All Layers" for Spot Healing, Blur, Sharpen and Smudge (layer pixels only: a mask or diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index 6f14ac8eb0..f13afc112e 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -119,7 +119,7 @@ pub fn live_impressionist(s: &Session, p: &Value) -> Result { } let (stroke, id) = (st.stroke.clone(), st.id); let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone(); - super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, true, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() + super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() } /// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 7f60b72c16..524e12f080 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1769,6 +1769,122 @@ fn live_dodge_matches_the_commit_across_cells() { assert!(worst <= 2e-4, "live and commit differ by {worst}"); } +/// The sequential-dab tools' options for the live hand-off tests. +fn dab_cases() -> [(&'static str, Value); 6] { + [ + ("paint.dodge", json!({"exposure": 60, "range": "midtones"})), + ("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false})), + ("paint.sponge", json!({"mode": "saturate"})), + ("paint.blur", json!({"strength": 80})), + ("paint.sharpen", json!({"strength": 60})), + ("paint.smudge", json!({"strength": 70})), + ] +} + +/// A stroke's params: `points`, a 40 px brush of `hardness`, and the tool's options. +fn dab_opts(points: &[[f64; 2]], hardness: u32, extra: &Value) -> Value { + let mut o = json!({"points": points, "size": 40, "hardness": hardness, "spacing": 25}); + for (k, v) in extra.as_object().unwrap() { + o[k] = v.clone(); + } + o +} + +/// The active layer's pixels over the whole canvas. +fn layer_pixels(s: &Session) -> Vec { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) +} + +/// Draw `points` live as the canvas does (the first with `begin`, the rest in pen batches that +/// carry tilt and times, which the commands don't take) and offer the stroke to its commit. +fn offer_live(s: &mut Session, cmd: &str, points: &[[f64; 2]], hardness: u32, extra: &Value) { + let mut live = LiveDab::begin(s, cmd, &dab_opts(&points[..1], hardness, extra)).unwrap(); + for (i, batch) in points[1..].chunks(2).enumerate() { + let pts: Vec<_> = batch + .iter() + .map(|q| { + let mut p = StrokePoint::new(q[0], q[1], 1.0); + (p.tilt_x, p.time) = (25.0, 16.0 * i as f64); + p + }) + .collect(); + live.push(&pts).unwrap(); + } + s.offer_live_dab(live).unwrap(); +} + +/// Releasing a live Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke commits the pixels it +/// already drew instead of running every dab again, and they are exactly what the command paints +/// on its own: at every depth (the live working copy isn't rounded between dabs), soft and hard, +/// over semi-transparent pixels and where the stroke doubles back. +#[test] +fn live_dab_strokes_commit_their_pixels_and_they_are_the_commands() { + let points = [[10.0, 40.0], [100.0, 60.0], [180.0, 40.0], [60.0, 30.0], [150.0, 70.0], [151.0, 70.5], [-15.0, 85.0]]; + for depth in DEPTHS { + let mut s = session(200, 90, depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_layer(&mut s, |x, y| { + let mut c = texture(x, y); + c[3] = if (x / 23 + y / 17) % 4 == 0 { 0.0 } else { 0.35 + 0.065 * ((x * 7 + y * 3) % 11) as f32 }; + c + }); + let before = layer_pixels(&s); + for (cmd, extra) in dab_cases() { + for hardness in [0, 100] { + let rendered = s.execute(cmd, dab_opts(&points, hardness, &extra)).unwrap(); + let want = layer_pixels(&s); + assert_ne!(want, before, "{cmd} {depth} {hardness}: the stroke changes pixels"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(rendered.get("fromLive").is_none()); + + offer_live(&mut s, cmd, &points, hardness, &extra); + let taken = s.execute(cmd, dab_opts(&points, hardness, &extra)).unwrap(); + assert_eq!(taken["fromLive"], json!(true), "{cmd} {depth} {hardness}"); + assert_eq!(taken["damage"], rendered["damage"], "{cmd} {depth} {hardness}"); + assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit, hardness {hardness}: the live pixels aren't the command's"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer_pixels(&s) == before, "{cmd} {depth} {hardness}: undo restores the layer"); + } + } + } +} + +/// The offer is only taken by its own stroke: another stroke, a brush or option changed during +/// the stroke, or a stroke whose commit renders itself (Color Replacement) render as before. +#[test] +fn live_dab_offers_are_refused_or_dropped_when_they_dont_match() { + let points = [[10.0, 40.0], [100.0, 60.0], [180.0, 40.0]]; + let blur = json!({"strength": 80}); + let mut s = session(200, 90, 8, "rgb"); + paint_layer(&mut s, texture); + // Another stroke drops the offer and renders itself. + offer_live(&mut s, "paint.blur", &points, 0, &blur); + let other = [[10.0, 40.0], [100.0, 61.0], [180.0, 40.0]]; + let r = s.execute("paint.blur", dab_opts(&other, 0, &blur)).unwrap(); + assert!(r.get("fromLive").is_none()); + assert!(s.live_commit.is_none(), "the offer is used up"); + s.execute("edit.undo", json!({})).unwrap(); + // The session brush (not in the params) changed during the stroke; the refusal also + // withdraws an earlier offer. + offer_live(&mut s, "paint.blur", &points, 0, &blur); + let live = LiveDab::begin(&s, "paint.blur", &dab_opts(&points, 0, &blur)).unwrap(); + let roundness = s.tools.brush.roundness; + s.tools.brush.roundness = roundness * 0.5; + assert!(s.offer_live_dab(live).is_err()); + assert!(s.live_commit.is_none(), "a refused offer leaves no earlier one behind"); + s.tools.brush.roundness = roundness; + // Finger Painting's colour changed during the stroke. + let finger = json!({"strength": 70, "fingerPainting": true}); + let live = LiveDab::begin(&s, "paint.smudge", &dab_opts(&points, 0, &finger)).unwrap(); + s.tools.foreground = [0.9, 0.1, 0.1, 1.0]; + assert!(s.offer_live_dab(live).is_err()); + // Color Replacement's live stroke runs on `LiveDab` too, but its commit renders itself. + let live = crate::brush_cmds::live_color_replacement(&s, &json!({"points": points, "color": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + assert!(s.offer_live_dab(live).is_err()); + assert!(s.live_commit.is_none()); +} + #[test] fn retouch_strokes_refuse_absurd_point_coordinates() { // #976: a segment to (1e300, 0) has an infinite length and the dab walker would never stop, so diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index b3fae926eb..8ca012266d 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -250,7 +250,7 @@ pub fn specs() -> Vec { label: "Transform Selection", menu: &["Select"], shortcut: None, - params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest"="bilinear"}"##, + params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##, enabled: has_selection, run: transform_selection, journal: true, @@ -297,6 +297,26 @@ mod tests { s } + /// #8697: Photoshop's bicubic variants are accepted and render as Bicubic, for a selection + /// and for a layer (an imported action keeps them). + #[test] + fn photoshop_bicubic_variants_transform_as_bicubic() { + let run = |id: &str, interpolation: &str| { + let mut s = landscape(8); + s.execute("select.rect", json!({"x": 20, "y": 10, "width": 60, "height": 40, "feather": 3})).unwrap(); + s.execute(id, json!({"scaleX": 137, "scaleY": 81, "rotate": 7, "interpolation": interpolation})).unwrap(); + let st = s.active().unwrap(); + (st.doc.selection.clone(), st.doc.layers.iter().map(|l| l.surface().cloned()).collect::>()) + }; + for id in ["select.transformSelection", "edit.transform"] { + let bicubic = run(id, "bicubic"); + assert_ne!(bicubic, run(id, "bilinear"), "{id}: the interpolation matters here"); + for variant in ["bicubicSharper", "bicubicSmoother", "bicubicAutomatic"] { + assert_eq!(run(id, variant), bicubic, "{id} {variant}"); + } + } + } + #[test] fn sky_selects_the_sky_at_every_depth() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/select_mask_edits.rs b/crates/engine/src/select_mask_edits.rs new file mode 100644 index 0000000000..9fd29a2757 --- /dev/null +++ b/crates/engine/src/select_mask_edits.rs @@ -0,0 +1,463 @@ +//! Select and Mask's own selection tools (#7069, #8148): the edits the workspace makes to the +//! selection before it refines the edge, carried as `select.refineEdge`'s `edits` param. +//! +//! Photoshop's Select and Mask has a Quick Selection tool, a Brush, Object Selection, Lasso and +//! Polygonal Lasso, a Select Subject button and Clear Selection / Invert. Here each use is one +//! [`SelectionEdit`], applied in order to the selection (or layer mask) Select and Mask starts +//! from. Keeping them as params rather than live edits of the document means Cancel discards +//! them, OK is one history step, and the UI, CLI, MCP and the control channel all drive them the +//! same way. [`apply`] is pure, so the workspace's preview runs exactly what OK commits. + +use std::collections::BTreeSet; + +use photocraft_algo::matting; +use photocraft_algo::segment::{grabcut, quick, subject}; +use photocraft_algo::selection::{self as sel, Region, SelectionMode, combine_region}; +use photocraft_doc::{Document, LayerId}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::Value; + +use crate::smartselect_cmds::with_doc_sampler; +use crate::{EngineError, Result}; + +const CMD: &str = "select.refineEdge"; + +/// Most edits one Select and Mask takes, and points across them. +pub const MAX_EDITS: usize = 4096; +pub const MAX_EDIT_POINTS: usize = 100_000; + +/// One use of a Select and Mask tool. `add: false` is the tool's subtract mode (Alt). +#[derive(Clone, Debug, PartialEq)] +pub enum SelectionEdit { + /// Quick Selection: grows a region from the stroke out to the image's edges. + Quick { points: Vec<(f32, f32)>, size: f32, add: bool, sample_all_layers: bool }, + /// Brush: paints the selection directly with a round brush. + Brush { points: Vec<(f32, f32)>, size: f32, hardness: f32, add: bool }, + /// Lasso and Polygonal Lasso: the polygon through `points`. + Lasso { points: Vec<(f32, f32)>, add: bool }, + /// Object Selection: the object in `rect` [x, y, w, h], or the one under `point`. + Object { area: ObjectArea, add: bool, sample_all_layers: bool }, + /// The Select Subject button. + Subject { mode: SelectionMode, sample_all_layers: bool }, + /// Global Refinements › Clear Selection. + Clear, + /// Global Refinements › Invert. + Invert, +} + +/// Where Object Selection looks. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ObjectArea { + Rect(Rect), + Point(f32, f32), +} + +fn bad(msg: &str) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +/// A finite coordinate within the range strokes may use. +fn coord(v: &Value) -> Option { + v.as_f64().filter(|v| v.is_finite() && v.abs() <= 1e7).map(|v| v as f32) +} + +fn point(v: &Value) -> Option<(f32, f32)> { + let xy = v.as_array().filter(|a| a.len() == 2)?; + Some((coord(xy.first()?)?, coord(xy.get(1)?)?)) +} + +fn points(o: &serde_json::Map, min: usize, total: &mut usize) -> Result> { + let list = o.get("points").and_then(Value::as_array).ok_or_else(|| bad("this edit needs a points array of [x,y] pairs"))?; + if list.len() < min { + return Err(bad(&format!("this edit needs at least {min} point{}", if min == 1 { "" } else { "s" }))); + } + *total = total.saturating_add(list.len()); + if *total > MAX_EDIT_POINTS { + return Err(bad(&format!("edits hold more than {MAX_EDIT_POINTS} points"))); + } + list.iter().map(point).collect::>>().ok_or_else(|| bad("edit points must be [x,y] pairs of finite numbers")) +} + +/// A number in `range`, `default` when absent; anything else is an error naming `key`. +fn number(o: &serde_json::Map, key: &str, default: f32, range: std::ops::RangeInclusive) -> Result { + match o.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .map(|v| v as f32) + .filter(|v| v.is_finite() && range.contains(v)) + .ok_or_else(|| bad(&format!("edit {key} must be a number in {}..{}", range.start(), range.end()))), + } +} + +fn flag(o: &serde_json::Map, key: &str, default: bool) -> Result { + match o.get(key) { + None => Ok(default), + Some(v) => v.as_bool().ok_or_else(|| bad(&format!("edit {key} must be true or false"))), + } +} + +/// The tools' `mode`: `add` (the default) or `subtract`. +fn adds(o: &serde_json::Map) -> Result { + match o.get("mode").map(Value::as_str) { + None | Some(Some("add")) => Ok(true), + Some(Some("subtract")) => Ok(false), + Some(_) => Err(bad("edit mode must be add|subtract")), + } +} + +/// The `edits` of `p`, in order (none when absent or null). +pub fn parse(p: &Value) -> Result> { + let Some(v) = p.get("edits").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad("edits must be an array of {\"tool\":…} objects"))?; + if list.len() > MAX_EDITS { + return Err(bad(&format!("edits holds more than {MAX_EDITS} edits"))); + } + let size_range = matting::BRUSH_SIZE_RANGE; + let mut total = 0usize; + // The area the Brush edits sweep in all, bounded as `refineStrokes`' is. + let mut swept = 0.0f64; + let mut edits = Vec::with_capacity(list.len()); + for e in list { + let o = e.as_object().ok_or_else(|| bad("each edit must be an object with a tool"))?; + let tool = o.get("tool").and_then(Value::as_str).ok_or_else(|| bad("each edit needs a tool: quick|brush|lasso|object|subject|clear|invert"))?; + let edit = match tool { + "quick" => SelectionEdit::Quick { + points: points(o, 1, &mut total)?, + size: number(o, "size", 30.0, size_range.clone())?, + add: adds(o)?, + sample_all_layers: flag(o, "sampleAllLayers", false)?, + }, + "brush" => { + let points = points(o, 1, &mut total)?; + let size = number(o, "size", 30.0, size_range.clone())?; + swept += matting::stroke_area(&points, size); + if swept > matting::MAX_SWEPT_AREA { + return Err(bad("brush edits paint too large an area")); + } + SelectionEdit::Brush { points, size, hardness: number(o, "hardness", 100.0, 0.0..=100.0)?, add: adds(o)? } + } + "lasso" => SelectionEdit::Lasso { points: points(o, 3, &mut total)?, add: adds(o)? }, + "object" => { + let area = match (o.get("rect"), o.get("point")) { + (Some(r), _) => { + let v = r.as_array().filter(|a| a.len() == 4).ok_or_else(|| bad("object rect must be [x,y,w,h]"))?; + let n = v.iter().map(coord).collect::>>().ok_or_else(|| bad("object rect must be four finite numbers"))?; + let (x, y, w, h) = (n.first().copied(), n.get(1).copied(), n.get(2).copied(), n.get(3).copied()); + let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad("object rect must be [x,y,w,h]")) }; + // Negative sizes (dragging up or left) are normalised. + let (x0, x1, y0, y1) = (x.min(x + w), x.max(x + w), y.min(y + h), y.max(y + h)); + ObjectArea::Rect(Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32)) + } + (None, Some(pt)) => { + let (x, y) = point(pt).ok_or_else(|| bad("object point must be [x,y]"))?; + ObjectArea::Point(x, y) + } + (None, None) => return Err(bad("object needs \"rect\": [x,y,w,h] or \"point\": [x,y]")), + }; + SelectionEdit::Object { area, add: adds(o)?, sample_all_layers: flag(o, "sampleAllLayers", false)? } + } + "subject" => { + let mode = match o.get("mode").map(Value::as_str) { + None | Some(Some("replace")) => SelectionMode::Replace, + Some(Some("add")) => SelectionMode::Add, + Some(Some("subtract")) => SelectionMode::Subtract, + Some(_) => return Err(bad("subject mode must be replace|add|subtract")), + }; + SelectionEdit::Subject { mode, sample_all_layers: flag(o, "sampleAllLayers", true)? } + } + "clear" => SelectionEdit::Clear, + "invert" => SelectionEdit::Invert, + other => return Err(bad(&format!("unknown edit tool `{other}`: quick|brush|lasso|object|subject|clear|invert"))), + }; + edits.push(edit); + } + Ok(edits) +} + +/// Whether `p` carries Select and Mask tool edits. +pub fn has_edits(p: &Value) -> bool { + p.get("edits").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) +} + +fn mode(add: bool) -> SelectionMode { + if add { SelectionMode::Add } else { SelectionMode::Subtract } +} + +/// Most tiles the Brush and Lasso edits may paint in all: four times what one may +/// ([`sel::MAX_PAINTED_TILES`]), 1 GiB of GRAY8 tiles. Each is rasterised a tile at a time, so +/// this, not the canvas size, bounds what they allocate (the #8783 review). +pub const MAX_EDIT_TILES: usize = 4 * sel::MAX_PAINTED_TILES; + +/// Refuses Brush and Lasso edits that would paint too many tiles of `canvas`, one by one or in +/// all, before anything is painted. +pub fn check_size(edits: &[SelectionEdit], canvas: Rect) -> Result<()> { + let mut all = BTreeSet::new(); + for e in edits { + let tiles = match e { + SelectionEdit::Brush { points, size, .. } => matting::painted_tiles(points, *size, canvas, sel::MAX_PAINTED_TILES), + SelectionEdit::Lasso { points, .. } => sel::polygon_edge_tiles(points, canvas, sel::MAX_PAINTED_TILES), + _ => continue, + }; + all.extend(tiles.ok_or_else(|| bad("a brush or lasso edit crosses too many tiles of the canvas"))?); + if all.len() > MAX_EDIT_TILES { + return Err(bad("brush and lasso edits cross too many tiles of the canvas")); + } + } + Ok(()) +} + +/// Refuses Refine Edge Brush strokes that would paint more tiles of `canvas` than +/// [`matting::EdgeBrush`] holds. +pub fn check_strokes(strokes: &[matting::EdgeStroke], canvas: Rect) -> Result<()> { + let refused = || EngineError::BadParams { cmd: CMD.into(), msg: "refineStrokes cross too many tiles of the canvas".into() }; + let mut all = BTreeSet::new(); + for s in strokes { + all.extend(matting::painted_tiles(&s.points, s.size, canvas, sel::MAX_PAINTED_TILES).ok_or_else(refused)?); + if all.len() > sel::MAX_PAINTED_TILES { + return Err(refused()); + } + } + Ok(()) +} + +/// `base` inverted over `canvas`; `None` when that leaves nothing selected. Tile by tile: the +/// canvas takes the inverse of `base`'s untouched value (one tile shared by every fully covered +/// tile, as `fill_rect` stores it), then only the tiles `base` holds are inverted, so neither a +/// sparse selection nor a huge canvas costs a canvas-sized buffer (the #8783 review). +fn invert(base: Option<&Surface>, canvas: Rect) -> Option { + let outside = base.map_or(0.0, |b| b.default_pixel().first().copied().unwrap_or(0.0).clamp(0.0, 1.0)); + let mut s = Surface::new(photocraft_color::PixelFormat::GRAY8); + if outside < 1.0 { + s.fill_rect(canvas, &[1.0 - outside]); + } + if let Some(b) = base { + let ch = b.channels().max(1); + for (tc, _) in b.tiles() { + let r = tc.rect().intersect(&canvas); + if r.is_empty() { + continue; + } + let inverted: Vec = b.read_region(r).chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect(); + s.write_region(r, &inverted); + } + } + s.prune(); + (!s.content_bounds().is_empty()).then_some(s) +} + +/// One edit applied to `base` (the selection so far; `None` = nothing selected) in `doc`, the +/// sampling tools reading `layer` (or the composite with Sample All Layers). A tool that finds +/// nothing leaves the selection as it was, as the same tools do outside Select and Mask. +pub fn apply_one(doc: &Document, layer: Option, base: Option, edit: &SelectionEdit) -> Option { + let canvas = doc.bounds(); + let combine = |region: Option, m: SelectionMode| match region { + Some(r) => combine_region(base.as_ref(), Some(&r), m), + None => base.clone(), + }; + // A painted shape (tile-sparse) added or subtracted, a tile at a time. + let paint = |shape: Option, add: bool| match (shape.filter(|s| s.tile_count() > 0), base.as_ref()) { + (None, _) => base.clone(), + (Some(s), None) => add.then_some(s), + (Some(s), Some(o)) => { + let out = if add { sel::combine_sparse(o, &s, canvas, f32::max) } else { sel::combine_sparse(o, &s, canvas, |a, b| (a - b).max(0.0)) }; + (!out.content_bounds().is_empty()).then_some(out) + } + }; + match edit { + SelectionEdit::Quick { points, size, add, sample_all_layers } => { + let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| quick::quick_select(smp, doc.bounds(), points, *size)); + combine(region, mode(*add)) + } + SelectionEdit::Brush { points, size, hardness, add } => paint(matting::brush_surface(points, *size, *hardness, canvas), *add), + SelectionEdit::Lasso { points, add } => paint(sel::polygon_surface(points, canvas), *add), + SelectionEdit::Object { area, add, sample_all_layers } => { + let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| match *area { + ObjectArea::Rect(r) => grabcut::object_select(smp, doc.bounds(), r, 160_000), + ObjectArea::Point(x, y) => grabcut::object_at(smp, doc.bounds(), x, y, grabcut::OBJECT_AT_WORK_PX), + }); + combine(region, mode(*add)) + } + SelectionEdit::Subject { mode, sample_all_layers } => { + let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| subject::select_subject(smp, doc.bounds())); + combine(region, *mode) + } + SelectionEdit::Clear => None, + SelectionEdit::Invert => invert(base.as_ref(), canvas), + } +} + +/// `edits` applied in order to `base`: the selection Select and Mask refines. +/// A run of Inverts is one Invert or none (pairs cancel exactly), so thousands of them cost at +/// most one pass each between other edits (the #8783 review). +pub fn apply(doc: &Document, layer: Option, base: Option, edits: &[SelectionEdit]) -> Option { + let mut sel = base; + let mut invert = false; + for e in edits { + if *e == SelectionEdit::Invert { + invert = !invert; + continue; + } + if std::mem::take(&mut invert) { + sel = apply_one(doc, layer, sel, &SelectionEdit::Invert); + } + sel = apply_one(doc, layer, sel, e); + } + if invert { + sel = apply_one(doc, layer, sel, &SelectionEdit::Invert); + } + sel +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_every_tool() { + let p = json!({"edits": [ + {"tool": "quick", "points": [[1, 2]], "size": 12, "mode": "subtract", "sampleAllLayers": true}, + {"tool": "brush", "points": [[1, 2], [3, 4]], "hardness": 50}, + {"tool": "lasso", "points": [[0, 0], [10, 0], [0, 10]]}, + {"tool": "object", "rect": [10, 10, -5, -5]}, + {"tool": "object", "point": [3, 4], "mode": "subtract"}, + {"tool": "subject", "mode": "add"}, + {"tool": "clear"}, + {"tool": "invert"}, + ]}); + let edits = parse(&p).expect("valid edits"); + assert_eq!(edits.len(), 8); + assert_eq!(edits[0], SelectionEdit::Quick { points: vec![(1.0, 2.0)], size: 12.0, add: false, sample_all_layers: true }); + assert_eq!(edits[1], SelectionEdit::Brush { points: vec![(1.0, 2.0), (3.0, 4.0)], size: 30.0, hardness: 50.0, add: true }); + assert_eq!(edits[3], SelectionEdit::Object { area: ObjectArea::Rect(Rect::new(5, 5, 10, 10)), add: true, sample_all_layers: false }); + assert_eq!(edits[5], SelectionEdit::Subject { mode: SelectionMode::Add, sample_all_layers: true }); + assert!(parse(&json!({})).expect("none").is_empty()); + assert!(parse(&json!({"edits": null})).expect("none").is_empty()); + assert!(has_edits(&p) && !has_edits(&json!({"edits": []})) && !has_edits(&json!({}))); + // One long brush edit within the area limit is fine; two of them (rejects_hostile_edits) are not. + assert!(parse(&json!({"edits": [{"tool": "brush", "points": [[0, 0], [10_000_000, 0]], "size": 20}]})).is_ok()); + } + + #[test] + fn rejects_hostile_edits() { + let many_points = vec![json!([1, 1]); MAX_EDIT_POINTS + 1]; + let many_edits = vec![json!({"tool": "clear"}); MAX_EDITS + 1]; + for bad in [ + json!("x"), + json!([1]), + json!([{}]), + json!([{"tool": 7}]), + json!([{"tool": "wand"}]), + json!([{"tool": "quick"}]), + json!([{"tool": "quick", "points": []}]), + json!([{"tool": "quick", "points": [[1]]}]), + json!([{"tool": "quick", "points": [["a", 1]]}]), + json!([{"tool": "quick", "points": [[1e300, 1]]}]), + json!([{"tool": "quick", "points": [[1, 1]], "size": 0}]), + json!([{"tool": "quick", "points": [[1, 1]], "size": "big"}]), + json!([{"tool": "quick", "points": [[1, 1]], "mode": "intersect"}]), + json!([{"tool": "quick", "points": [[1, 1]], "sampleAllLayers": 1}]), + json!([{"tool": "brush", "points": [[1, 1]], "hardness": 101}]), + json!([{"tool": "lasso", "points": [[1, 1], [2, 2]]}]), + json!([{"tool": "object"}]), + json!([{"tool": "object", "rect": [1, 2, 3]}]), + json!([{"tool": "object", "point": [1, "y"]}]), + json!([{"tool": "subject", "mode": "intersect"}]), + json!([{"tool": "brush", "points": many_points}]), + json!(many_edits), + // Each within the limit, together past it. + json!(vec![json!({"tool": "brush", "points": [[0, 0], [10_000_000, 0]], "size": 20}); 2]), + ] { + assert!(parse(&json!({"edits": bad})).is_err(), "{bad}"); + } + } + + /// The dense inversion this replaced: the whole canvas read, inverted and written back. + fn dense_invert(base: Option<&Surface>, canvas: Rect) -> Vec { + let m: Vec = sel::mask_from_surface(base, canvas).into_iter().map(|v| 1.0 - v).collect(); + bytes(Some(&sel::mask_to_surface(&m, canvas)), canvas) + } + + fn bytes(s: Option<&Surface>, canvas: Rect) -> Vec { + sel::mask_from_surface(s, canvas).into_iter().map(|v| (v * 255.0).round() as u8).collect() + } + + /// Tile by tile, Invert gives exactly what the dense inversion gave: partial edge tiles, soft + /// values, a selection reaching past the canvas, a mask whose untouched pixels are white, and + /// nothing selected (everything, inverted). + #[test] + fn tiled_invert_matches_the_dense_one() { + let canvas = Rect::new(0, 0, 600, 333); + let mut soft = Surface::new(photocraft_color::PixelFormat::GRAY8); + soft.fill_rect(Rect::new(250, 20, 900, 300), &[1.0]); + soft.fill_rect(Rect::new(10, 10, 40, 30), &[0.4]); + let mut white = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]); + white.fill_rect(Rect::new(300, 100, 520, 140), &[0.0]); + for base in [None, Some(soft), Some(white)] { + let got = bytes(invert(base.as_ref(), canvas).as_ref(), canvas); + assert_eq!(got, dense_invert(base.as_ref(), canvas)); + } + // Inverting everything selects nothing. + let mut all = Surface::new(photocraft_color::PixelFormat::GRAY8); + all.fill_rect(canvas, &[1.0]); + assert!(invert(Some(&all), canvas).is_none()); + } + + /// A huge canvas inverts without a canvas-sized buffer, and a run of Inverts costs one. + #[test] + fn invert_on_a_huge_canvas_and_runs_of_inverts() { + let doc = Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut small = Surface::new(photocraft_color::PixelFormat::GRAY8); + small.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]); + let t = std::time::Instant::now(); + let one = apply(&doc, None, Some(small.clone()), &[SelectionEdit::Invert]).expect("almost everything selected"); + assert!(one.sample_channel(15, 15, 0) < 0.01 && one.sample_channel(99_999, 99_999, 0) > 0.99 && one.sample_channel(50_000, 5, 0) > 0.99); + let odd = apply(&doc, None, Some(small.clone()), &vec![SelectionEdit::Invert; 4095]).expect("inverted"); + assert!(odd.sample_channel(15, 15, 0) < 0.01 && odd.sample_channel(99_999, 99_999, 0) > 0.99); + let even = apply(&doc, None, Some(small.clone()), &vec![SelectionEdit::Invert; 4096]).expect("the selection itself"); + assert!(even.sample_channel(15, 15, 0) > 0.99 && even.sample_channel(50_000, 50_000, 0) < 0.01); + // Runs split by another edit invert on each side of it. + let split = [SelectionEdit::Invert, SelectionEdit::Invert, SelectionEdit::Invert, SelectionEdit::Clear, SelectionEdit::Invert]; + let all = apply(&doc, None, Some(small), &split).expect("cleared, then everything"); + assert!(all.sample_channel(15, 15, 0) > 0.99 && all.sample_channel(70_000, 3, 0) > 0.99); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + } + + /// The #8783 review: a size-1 Brush edit from corner to corner of a 100,000 px canvas sweeps + /// about 141,000 px but its bounding box is 10 billion; it, a Lasso sliver along it and their + /// subtraction paint only the tiles they cross, and keep the selection there was. + #[test] + fn thin_edits_across_a_huge_canvas_paint_only_their_tiles() { + let doc = Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8); + let canvas = doc.bounds(); + let mut small = Surface::new(photocraft_color::PixelFormat::GRAY8); + small.fill_rect(Rect::new(10, 60, 20, 70), &[1.0]); + let diagonal = vec![(0.0, 0.0), (99_999.0, 99_999.0)]; + let brush = SelectionEdit::Brush { points: diagonal.clone(), size: 1.0, hardness: 100.0, add: true }; + let lasso = SelectionEdit::Lasso { points: vec![(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)], add: true }; + assert!(check_size(&[brush.clone(), lasso.clone()], canvas).is_ok()); + let t = std::time::Instant::now(); + for edit in [&brush, &lasso] { + let s = apply(&doc, None, Some(small.clone()), std::slice::from_ref(edit)).expect("painted"); + assert!(s.tile_count() < 1500, "{}", s.tile_count()); + assert!(s.sample_channel(15, 65, 0) > 0.99 && s.sample_channel(50_000, 50_000, 0) > 0.0 && s.sample_channel(50_000, 40_000, 0) == 0.0); + } + let erased = + apply(&doc, None, Some(small.clone()), &[brush, SelectionEdit::Brush { points: diagonal, size: 9.0, hardness: 100.0, add: false }]).expect("kept"); + assert!(erased.sample_channel(15, 65, 0) > 0.99 && erased.sample_channel(50_000, 50_000, 0) == 0.0); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + // A zigzag across more tiles than one edit may paint is refused before painting. + 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(); + let over = [SelectionEdit::Brush { points: zigzag.clone(), size: 1.0, hardness: 100.0, add: true }]; + assert!(check_size(&over, canvas).is_err()); + assert!(check_size(&[SelectionEdit::Lasso { points: zigzag, add: true }], canvas).is_err()); + // So are many edits that each fit but cross too many tiles in all. + let rows: Vec = (0..48) + .map(|i| SelectionEdit::Brush { points: vec![(0.0, i as f32 * 2000.0), (99_999.0, i as f32 * 2000.0)], size: 1.0, hardness: 100.0, add: true }) + .collect(); + assert!(rows.iter().all(|e| check_size(std::slice::from_ref(e), canvas).is_ok())); + assert!(check_size(&rows, canvas).is_err()); + } +} diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index fdd69e4568..9949bddea0 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -79,10 +79,16 @@ fn base_name(path: &str) -> String { /// A linked file's bytes; `None` when it is missing or its path is not followed /// ([`crate::doc_path::resolve`]). +/// +/// The path is document data, not a request parameter, so every read rechecks it with the gate +/// of the session the work is done for: a filter, transform or refresh re-renders from here, +/// not only the contents commands that judge it up front. A refused path is handled like a +/// missing file: the layer keeps its last rendering. fn read_file(path: &str) -> Option> { if path.is_empty() { return None; } + crate::jobs::authorize_render_command("layer.smartObjects.updateModifiedContent", &json!({"path": path})).ok()?; photocraft_format::read_file(&crate::doc_path::resolve(path).ok()?).ok() } @@ -180,6 +186,33 @@ pub fn source_decode_count(bytes: &[u8], fmt: PixelFormat) -> u32 { SOURCE_CACHE.lock().ok().and_then(|c| c.iter().find(|e| e.key == key).map(|e| e.decodes)).unwrap_or(0) } +pub(crate) fn take_caches(layers: &mut [Layer]) -> Vec<(LayerId, Surface)> { + let mut out = Vec::new(); + for l in layers { + match &mut l.content { + LayerContent::Smart(sm) => out.extend(sm.cache.take().map(|c| (l.id, c))), + LayerContent::Group(g) => out.extend(take_caches(&mut g.children)), + _ => {} + } + } + out +} + +pub(crate) fn restore_unrendered( + doc: &mut Document, + taken: Vec<(LayerId, Surface)>, + mut fallback: impl FnMut(&Surface) -> std::result::Result, +) -> std::result::Result<(), E> { + for (id, cache) in taken { + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) + && sm.cache.is_none() + { + sm.cache = Some(fallback(&cache)?); + } + } + Ok(()) +} + fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Surface { let n = fmt.channels(); let mut data = vec![0.0f32; buf.px.len() * n]; @@ -956,6 +989,13 @@ fn replace_contents(s: &mut Session, p: &Value) -> Result { replace_contents_bytes(s, id, &base_name(&path), bytes) } +/// Replace Contents with a file the caller already read, for the smart object `p` names (`layer`, +/// else the active layer): the body of the command minus its ambient path read (#8061). +pub fn replace_contents_from_bytes(s: &mut Session, p: &Value, file_name: &str, bytes: Vec) -> Result { + let id = layer_param(s, p)?; + replace_contents_bytes(s, id, file_name, bytes) +} + /// Replace Contents from a file's name and bytes: what the desktop and web file pickers hand over. pub fn replace_contents_bytes(s: &mut Session, id: LayerId, file_name: &str, bytes: Vec) -> Result { decode_source(file_name, &bytes)?; // fail before touching the document diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 039f248a5b..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> { @@ -398,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. @@ -419,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() { @@ -532,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 ), @@ -1065,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/style_asl_cmds.rs b/crates/engine/src/style_asl_cmds.rs new file mode 100644 index 0000000000..8d80c816a1 --- /dev/null +++ b/crates/engine/src/style_asl_cmds.rs @@ -0,0 +1,327 @@ +//! 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()); + match s.patterns.items.iter_mut().find(|q| q.id == pat.id) { + Some(q) => *q = pat, + None => s.patterns.items.push(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); + } + + #[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/tests.rs b/crates/engine/src/tests.rs index 0b1b48aa3b..9e06c966de 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1789,3 +1789,37 @@ 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"); +} diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index fce8c1be49..1df4b1acee 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -838,13 +838,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 +863,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 820338bf2d..82ac7a7915 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -540,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| { @@ -965,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); @@ -1676,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 fd54db1fed..99538edb70 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -45,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()), } } @@ -54,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()), } } @@ -71,7 +72,7 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> { /// Edits a type layer as one history step and re-renders it. `f` gets the document's resolution /// (dpi). -fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32) -> Result) -> Result { +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, _| { @@ -416,7 +417,7 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result { 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()), } } @@ -424,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})) 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 0560a9d593..67aa1fe4f4 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -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,6 +415,10 @@ 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(); @@ -432,7 +442,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/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 4a94c1e9ab..ccfc3a472a 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -506,7 +506,8 @@ pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) let canvas = doc.bounds(); let inside = sh.cache.as_ref().is_some_and(|c| { let b = photocraft_compose::bounds::content_bounds(c); - b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1) + // 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)| { diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 7713e0d36f..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); @@ -256,6 +260,9 @@ type ImportFrames = fn( &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. @@ -281,8 +288,23 @@ 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(); @@ -293,12 +315,19 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result None, }; if let Some(import) = animation { - import(&name, &bytes, &mut |doc, delay_ms| { - frames.push(crate::file_cmds::flattened(&doc, fmt)); + 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)?; @@ -306,7 +335,8 @@ 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 { @@ -450,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); @@ -495,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 a3f304ce8e..3560c5f2c3 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -669,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/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 31de399b18..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), @@ -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 1ed070c020..3738aa6ca3 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -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(); @@ -1401,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; @@ -1433,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. @@ -1480,12 +1492,19 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { } /// `/-assets`. +/// 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) +} + pub fn assets_dir(doc_path: &str) -> String { let p = std::path::Path::new(doc_path); let dir = p.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); 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; @@ -1515,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()?); @@ -1532,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", @@ -1566,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 9ba644719d..38fa82c188 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -513,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] @@ -610,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(); @@ -645,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. 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/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs similarity index 100% rename from crates/engine/tests/atn_crystallize_cell_size.rs rename to crates/engine/tests/it/atn_crystallize_cell_size.rs 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/background_and_mask_depth.rs b/crates/engine/tests/it/background_and_mask_depth.rs similarity index 100% rename from crates/engine/tests/background_and_mask_depth.rs rename to crates/engine/tests/it/background_and_mask_depth.rs 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/convert_keeps_references.rs b/crates/engine/tests/it/convert_keeps_references.rs similarity index 100% rename from crates/engine/tests/convert_keeps_references.rs rename to crates/engine/tests/it/convert_keeps_references.rs 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 100% rename from crates/engine/tests/corpus_atn.rs rename to crates/engine/tests/it/corpus_atn.rs diff --git a/crates/engine/tests/crop_content_aware.rs b/crates/engine/tests/it/crop_content_aware.rs similarity index 100% rename from crates/engine/tests/crop_content_aware.rs rename to crates/engine/tests/it/crop_content_aware.rs diff --git a/crates/engine/tests/current_affinity_artboards.rs b/crates/engine/tests/it/current_affinity_artboards.rs similarity index 97% rename from crates/engine/tests/current_affinity_artboards.rs rename to crates/engine/tests/it/current_affinity_artboards.rs index e3b27e856b..8348b4065f 100644 --- a/crates/engine/tests/current_affinity_artboards.rs +++ b/crates/engine/tests/it/current_affinity_artboards.rs @@ -1,7 +1,7 @@ //! Artboards recognized from current .af properties move with their children and undo together. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -#[path = "../../io/tests/support/current_affinity.rs"] +#[path = "../../../io/tests/support/current_affinity.rs"] mod fixture; use fixture::{Marker, document}; diff --git a/crates/engine/tests/duplicate_layer_comps.rs b/crates/engine/tests/it/duplicate_layer_comps.rs similarity index 100% rename from crates/engine/tests/duplicate_layer_comps.rs rename to crates/engine/tests/it/duplicate_layer_comps.rs diff --git a/crates/engine/tests/duplicate_slices.rs b/crates/engine/tests/it/duplicate_slices.rs similarity index 100% rename from crates/engine/tests/duplicate_slices.rs rename to crates/engine/tests/it/duplicate_slices.rs diff --git a/crates/engine/tests/duplicate_variables.rs b/crates/engine/tests/it/duplicate_variables.rs similarity index 100% rename from crates/engine/tests/duplicate_variables.rs rename to crates/engine/tests/it/duplicate_variables.rs diff --git a/crates/engine/tests/empty_selection_combine.rs b/crates/engine/tests/it/empty_selection_combine.rs similarity index 100% rename from crates/engine/tests/empty_selection_combine.rs rename to crates/engine/tests/it/empty_selection_combine.rs diff --git a/crates/engine/tests/eraser_locked.rs b/crates/engine/tests/it/eraser_locked.rs similarity index 100% rename from crates/engine/tests/eraser_locked.rs rename to crates/engine/tests/it/eraser_locked.rs diff --git a/crates/engine/tests/explicit_layer_target.rs b/crates/engine/tests/it/explicit_layer_target.rs similarity index 100% rename from crates/engine/tests/explicit_layer_target.rs rename to crates/engine/tests/it/explicit_layer_target.rs diff --git a/crates/engine/tests/export_shape_strokes.rs b/crates/engine/tests/it/export_shape_strokes.rs similarity index 100% rename from crates/engine/tests/export_shape_strokes.rs rename to crates/engine/tests/it/export_shape_strokes.rs 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/open_identity.rs b/crates/engine/tests/it/open_identity.rs similarity index 100% rename from crates/engine/tests/open_identity.rs rename to crates/engine/tests/it/open_identity.rs 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/smart_object_tones.rs b/crates/engine/tests/it/smart_object_tones.rs similarity index 100% rename from crates/engine/tests/smart_object_tones.rs rename to crates/engine/tests/it/smart_object_tones.rs 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/type_postscript_font.rs b/crates/engine/tests/it/type_postscript_font.rs similarity index 100% rename from crates/engine/tests/type_postscript_font.rs rename to crates/engine/tests/it/type_postscript_font.rs 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/unsupported_smart_filters.rs b/crates/engine/tests/it/unsupported_smart_filters.rs similarity index 100% rename from crates/engine/tests/unsupported_smart_filters.rs rename to crates/engine/tests/it/unsupported_smart_filters.rs 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/xmp_charref.rs b/crates/engine/tests/it/xmp_charref.rs similarity index 100% rename from crates/engine/tests/xmp_charref.rs rename to crates/engine/tests/it/xmp_charref.rs 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 635bf924a2..8cd349efff 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -106,7 +106,7 @@ impl AtomicFs for RealFs { 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(windows)] { @@ -254,14 +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, 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))?; - // Keep the existing Unix policy here; #5420 separately changes its error handling. #[cfg(not(windows))] - let _ = fs.sync_dir(&dir); + 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<()> { diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 853a11293d..bc26ae6d0a 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -41,8 +41,9 @@ struct Faulty { /// Fail the first N renames with PermissionDenied (a transient lock), then succeed. locked_renames: Cell, renames: Cell, - /// Failure of the durability barrier after publication (#7858). - fail_sync_dir: bool, + /// 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, } @@ -72,10 +73,11 @@ impl AtomicFs for Faulty { /// Count publication barriers and optionally fail before the real directory flush. fn sync_dir(&self, dir: &Path) -> io::Result<()> { self.synced.set(self.synced.get() + 1); - if self.fail_sync_dir { - return Err(io::Error::other("injected publication flush failure")); + 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), } - RealFs.sync_dir(dir) } fn copy_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { RealFs.copy_metadata(from, to) @@ -92,7 +94,7 @@ fn a_failed_publication_flush_is_reported_and_can_be_retried() { let d = TempDir::new("publication-flush"); let p = d.0.join("document.pcraft"); std::fs::write(&p, ORIGINAL).unwrap(); - let fs = Faulty { fail_sync_dir: true, ..Default::default() }; + let 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}"); @@ -328,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() { @@ -431,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 fda505dcff..65f06fd8e2 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -236,7 +236,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(), @@ -581,7 +581,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/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 03a85cbc83..84124407e4 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -587,6 +587,50 @@ fn validate_bundle_file(root: &Path, path: &Path) -> Result<()> { plain_file_or_missing(path) } +/// 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<()> { @@ -598,7 +642,14 @@ fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atom 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; @@ -632,10 +683,10 @@ fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atom { fs.sync_dir(parent).map_err(|e| crate::atomic::publication_error(e, path))?; } - #[cfg(unix)] - { - let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); - } + // 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() { @@ -987,6 +1038,13 @@ impl PcraftWriter { 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. @@ -1149,6 +1207,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"] { 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..c58b542750 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))); @@ -228,7 +233,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 99% rename from crates/format/tests/corruption.rs rename to crates/format/tests/it/corruption.rs index 727b37cb70..fd2d0602f6 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/it/corruption.rs @@ -1,6 +1,6 @@ //! Damaged or hostile bundles must fail cleanly, never panic. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_format::*; diff --git a/crates/format/tests/directory_symlinks.rs b/crates/format/tests/it/directory_symlinks.rs similarity index 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 93% rename from crates/format/tests/roundtrip.rs rename to crates/format/tests/it/roundtrip.rs index b123214a58..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}; diff --git a/crates/format/tests/smart_contents.rs b/crates/format/tests/it/smart_contents.rs similarity index 100% rename from crates/format/tests/smart_contents.rs rename to crates/format/tests/it/smart_contents.rs diff --git a/crates/geom/src/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 8ba2bdd043..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; @@ -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)); @@ -1384,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/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/src/compose.wgsl b/crates/gpu/src/compose.wgsl index a6c0c63579..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); @@ -747,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 @@ -1187,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); @@ -1252,13 +1272,11 @@ 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); } } var cov = k * op.opacity; 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..18e255feb0 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); @@ -192,6 +234,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 +286,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..f9b6f11e57 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -468,6 +468,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, @@ -613,8 +619,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 +637,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 +684,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 +694,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 +740,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 +792,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, @@ -751,7 +808,8 @@ impl Compositor { 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 +842,14 @@ impl Compositor { self.page } - /// Set the GPU memory resident layer pages may hold (default [`RESIDENT_BUDGET`]). + /// 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 +860,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 +1143,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 +1204,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(); } diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 815b55517d..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; @@ -1645,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), @@ -1700,6 +1711,8 @@ 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 } => { @@ -1876,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()); @@ -1968,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/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..2c070f19ce --- /dev/null +++ b/crates/gpu/tests/it/main.rs @@ -0,0 +1,12 @@ +//! The crate's integration tests, linked into one test binary. + +/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so every test that +/// creates a device takes this lock and holds it until the device is dropped. +static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +mod device_loss; +mod parity; +mod 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 97% rename from crates/gpu/tests/parity.rs rename to crates/gpu/tests/it/parity.rs index 400c5a03b7..65ddbe33c2 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); @@ -957,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, @@ -1688,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, }], ), ( @@ -1759,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)))), ], ), @@ -1879,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]); @@ -2918,6 +2944,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] 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 94% rename from crates/gpu/tests/timing.rs rename to crates/gpu/tests/it/timing.rs index 24c2057253..78a1864cc3 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"); 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 eb8c0b00d7..9b4ab1f416 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" # SVG import (svg.rs): usvg parses, resvg rasterises what the shape model can't hold. Both are # already in the tree through the UI's icon loader; text shaping needs the fonts feature set. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -54,4 +54,5 @@ 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 } 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 0d3f497c75..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 { 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 e6125ef980..f794e1be40 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -47,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 }) } @@ -443,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 } 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/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 7786c28351..afff94853d 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -282,7 +282,7 @@ 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}")))?, @@ -433,32 +434,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 }; @@ -468,15 +461,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]); @@ -486,6 +472,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 { @@ -493,29 +553,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) 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 a0fc3e87e2..44beb59d47 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; @@ -232,6 +233,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 +250,7 @@ impl Default for ExportOptions { psd_maximize_compatibility: true, document_path: None, pcraft_cache: None, + read_linked_files: true, } } } @@ -343,14 +348,32 @@ pub fn import_with_options(name: &str, bytes: &[u8], opts: &ImportOptions) -> Re /// [`import_with`] with the user's import settings. pub fn import_with_options_and_interrupt(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result { - ctl.check().map_err(|_| IoError::Cancelled)?; - let r = import_stages(name, bytes, ctl, opts)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - ctl.progress(1.0); - Ok(r) + import_for_open_with(name, bytes, opts, ctl).map(|(result, _)| result) } -fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> 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, @@ -362,13 +385,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, opts: &ImportOptions) -> 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)?; @@ -377,7 +404,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // Magic first: an Affinity file renamed .psd is still Affinity (#5258 corpus // preview_never_saves_back_to_its_native_or_renamed_source). if is_psd(bytes) { - return import_psd(name, bytes, ctl); + return import_psd(name, bytes, ctl, damaged); } if affinity::is_affinity(bytes) || affinity::has_extension(name) { return affinity::import(name, bytes); @@ -410,7 +437,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // A `.psd`/`.psb`/`.psdt` with no raster signature keeps the PSD error instead of // `codec: unrecognized image format` (#5258). Magic already returned above. if is_psd_input(name, bytes) { - return import_psd(name, bytes, ctl); + return import_psd(name, bytes, ctl, damaged); } flat::import_flat(name, bytes) } @@ -526,6 +553,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)); @@ -641,7 +669,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/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..922135a42e 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -327,9 +327,29 @@ 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] 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/psd_export.rs b/crates/io/src/psd_export.rs index 90a5d86ce9..4835397822 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, @@ -158,8 +169,9 @@ 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>, /// 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. @@ -375,6 +387,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; @@ -630,8 +649,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 +677,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()); @@ -977,16 +1000,20 @@ 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 } => self.embed(sm.contents_id, file_name, bytes, false), } } @@ -1031,17 +1058,19 @@ 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). + fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result { + let key = (contents_id, *blake3::hash(bytes).as_bytes(), linked); 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")); @@ -1049,7 +1078,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, ); @@ -1059,9 +1088,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); @@ -1338,6 +1371,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(); @@ -1395,6 +1431,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*. @@ -1534,7 +1621,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()?)?; @@ -1585,7 +1672,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; @@ -1595,6 +1682,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), @@ -2102,6 +2190,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 d2cd6d967c..415bf1d320 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -51,12 +51,21 @@ 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). @@ -132,6 +141,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 +178,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 +197,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 +219,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 +233,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 +250,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 +285,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) { @@ -377,10 +403,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) @@ -446,7 +478,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, @@ -512,7 +561,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)) @@ -647,6 +696,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", @@ -818,6 +870,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) @@ -853,6 +906,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(|| { @@ -877,7 +941,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 => { @@ -919,7 +983,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() @@ -944,9 +1010,13 @@ 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(), ctl: *ctl, @@ -983,15 +1053,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 }; @@ -1094,7 +1164,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"); } @@ -1120,7 +1190,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}")), } } @@ -1135,7 +1205,10 @@ pub(crate) fn psd_to_document_decoded( 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(); 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 s = Surface::new(cx.mask_fmt); s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); s.prune(); @@ -1173,7 +1246,7 @@ 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)] @@ -1218,6 +1291,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]; 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 ac1bd27ffa..6363945d7e 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). @@ -139,9 +208,36 @@ fn system_generic_family(_generic: &str) -> Option { /// `~/.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, ..usvg::Options::default() } @@ -174,7 +270,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 }) } @@ -599,7 +699,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))) } @@ -1058,4 +1176,66 @@ mod tests { assert_eq!((cv.size.width, cv.size.height), (MAX_SIDE, MAX_SIDE / 2)); assert_eq!(w.len(), 1); } + + #[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..25c2df5010 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -216,6 +216,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr 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 @@ -276,7 +277,7 @@ 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), diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index bb830dbd68..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 [ 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 831529d115..b7695d451a 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/it/corpus.rs @@ -44,7 +44,7 @@ //! asserts import + flatten return (Ok or Err) without panicking. #![cfg(feature = "corpus")] -mod common; +use crate::common; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; 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 99% rename from crates/io/tests/flat.rs rename to crates/io/tests/it/flat.rs index 98833d45d5..353e2bbb7a 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/it/flat.rs @@ -1,6 +1,6 @@ //! Flat formats through photocraft-codecs, and export warnings. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; 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/layer_xmp.rs b/crates/io/tests/it/layer_xmp.rs similarity index 100% rename from crates/io/tests/layer_xmp.rs rename to crates/io/tests/it/layer_xmp.rs diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs new file mode 100644 index 0000000000..94f4ba715d --- /dev/null +++ b/crates/io/tests/it/main.rs @@ -0,0 +1,80 @@ +//! 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 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/orientation.rs b/crates/io/tests/it/orientation.rs similarity index 100% rename from crates/io/tests/orientation.rs rename to crates/io/tests/it/orientation.rs diff --git a/crates/io/tests/pattern_fill_psd.rs b/crates/io/tests/it/pattern_fill_psd.rs similarity index 100% rename from crates/io/tests/pattern_fill_psd.rs rename to crates/io/tests/it/pattern_fill_psd.rs diff --git a/crates/io/tests/pcraft_manifest_name.rs b/crates/io/tests/it/pcraft_manifest_name.rs similarity index 98% rename from crates/io/tests/pcraft_manifest_name.rs rename to crates/io/tests/it/pcraft_manifest_name.rs index 285fa02203..fffb88fa8b 100644 --- a/crates/io/tests/pcraft_manifest_name.rs +++ b/crates/io/tests/it/pcraft_manifest_name.rs @@ -1,7 +1,7 @@ //! A `.pcraft` bundle's manifest reports the file it is in (#3312): writing a document to a new //! name must not leave the previous one inside, on every path that produces `.pcraft` bytes. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/pdn_corpus.rs b/crates/io/tests/it/pdn_corpus.rs similarity index 98% rename from crates/io/tests/pdn_corpus.rs rename to crates/io/tests/it/pdn_corpus.rs index 1e67b3d22b..c85b87f3ee 100644 --- a/crates/io/tests/pdn_corpus.rs +++ b/crates/io/tests/it/pdn_corpus.rs @@ -1,5 +1,5 @@ //! Local PDN oracles are not bundled. Set PDN_TEST_DIR to a directory containing PDNs and their -//! exported PNGs, then run `cargo test -p photocraft-io --test pdn_corpus -- --ignored --nocapture`. +//! exported PNGs, then run `cargo test -p photocraft-io --test it -- pdn_corpus:: --ignored --nocapture`. use std::path::{Path, PathBuf}; diff --git a/crates/io/tests/png_text_metadata.rs b/crates/io/tests/it/png_text_metadata.rs similarity index 100% rename from crates/io/tests/png_text_metadata.rs rename to crates/io/tests/it/png_text_metadata.rs diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/it/psd16_merged_transparency.rs similarity index 99% rename from crates/io/tests/psd16_merged_transparency.rs rename to crates/io/tests/it/psd16_merged_transparency.rs index 02d6c0e6b3..82b054e873 100644 --- a/crates/io/tests/psd16_merged_transparency.rs +++ b/crates/io/tests/it/psd16_merged_transparency.rs @@ -5,7 +5,7 @@ //! Photoshop's 16-bit PSB in the report has four channels, a positive layer count in `Lr16`, no //! `Mt16` block, and one alpha channel name, "Transparency". Re-saving it wrote five channels. -mod common; +use crate::common; use common::raster; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/psd32_export.rs b/crates/io/tests/it/psd32_export.rs similarity index 99% rename from crates/io/tests/psd32_export.rs rename to crates/io/tests/it/psd32_export.rs index b4707fadb0..f9a297a7f6 100644 --- a/crates/io/tests/psd32_export.rs +++ b/crates/io/tests/it/psd32_export.rs @@ -6,7 +6,7 @@ //! resources, layer info in an `Lr32` global block with an empty classic layer info, channel //! compression codes valid for 32-bit data, and a raw big-endian float merged image. -mod common; +use crate::common; use common::Features; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/psd_background.rs b/crates/io/tests/it/psd_background.rs similarity index 100% rename from crates/io/tests/psd_background.rs rename to crates/io/tests/it/psd_background.rs diff --git a/crates/io/tests/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 100% rename from crates/io/tests/psd_kerning.rs rename to crates/io/tests/it/psd_kerning.rs diff --git a/crates/io/tests/psd_layer_block_length.rs b/crates/io/tests/it/psd_layer_block_length.rs similarity index 99% rename from crates/io/tests/psd_layer_block_length.rs rename to crates/io/tests/it/psd_layer_block_length.rs index acb4a6b15c..71f8e2552d 100644 --- a/crates/io/tests/psd_layer_block_length.rs +++ b/crates/io/tests/it/psd_layer_block_length.rs @@ -4,7 +4,7 @@ //! Photoshop writes these blocks this way. Readers that check the block length exactly (Unity's //! PSD Importer, "Incorrect length for InfoLayers") reject a block whose length counts the padding. -mod common; +use crate::common; use common::Features; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/psd_maximize_compatibility.rs b/crates/io/tests/it/psd_maximize_compatibility.rs similarity index 100% rename from crates/io/tests/psd_maximize_compatibility.rs rename to crates/io/tests/it/psd_maximize_compatibility.rs diff --git a/crates/io/tests/psd_noise_fill_roundtrip.rs b/crates/io/tests/it/psd_noise_fill_roundtrip.rs similarity index 100% rename from crates/io/tests/psd_noise_fill_roundtrip.rs rename to crates/io/tests/it/psd_noise_fill_roundtrip.rs diff --git a/crates/io/tests/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 100% rename from crates/io/tests/raw.rs rename to crates/io/tests/it/raw.rs 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..1031b5504a 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/it/roundtrip.rs @@ -1,6 +1,6 @@ //! Document → PSD → Document round trips. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; 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 d1baf418fa..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!( @@ -245,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 100% rename from crates/io/tests/text_corpus.rs rename to crates/io/tests/it/text_corpus.rs diff --git a/crates/io/tests/text_styles.rs b/crates/io/tests/it/text_styles.rs similarity index 100% rename from crates/io/tests/text_styles.rs rename to crates/io/tests/it/text_styles.rs diff --git a/crates/io/tests/tiff_corpus.rs b/crates/io/tests/it/tiff_corpus.rs similarity index 100% rename from crates/io/tests/tiff_corpus.rs rename to crates/io/tests/it/tiff_corpus.rs diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs similarity index 97% rename from crates/io/tests/tiff_layers.rs rename to crates/io/tests/it/tiff_layers.rs index 56e51bf9f9..1bbe8e2f4b 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -2,7 +2,7 @@ //! trips in every mode and depth, equivalence with the PSD path, the flat fall-backs, files //! written by an independent implementation (psdtags, both byte orders), and malformed input. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; @@ -333,21 +333,28 @@ fn damaged_layer_data_opens_flattened() { 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) { + // 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) { + 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) { + 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) { + for i in (0..bytes.len()).step_by(5).filter(|_| runs()) { let mut b = bytes.clone(); b[i] ^= 0xff; let _ = import(name, &b); 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 100% rename from crates/io/tests/tiff_type_geometry.rs rename to crates/io/tests/it/tiff_type_geometry.rs 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/ipp/src/lib.rs b/crates/ipp/src/lib.rs index d0535a86ce..7fc6532862 100644 --- a/crates/ipp/src/lib.rs +++ b/crates/ipp/src/lib.rs @@ -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. @@ -321,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. @@ -392,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", @@ -415,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) } @@ -434,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); @@ -455,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 { @@ -483,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 }; @@ -552,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) } @@ -595,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), @@ -631,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 394ff758d1..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); @@ -429,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 @@ -440,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/src/dynamics.rs b/crates/paint/src/dynamics.rs index 41c1bb6b4d..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) => { @@ -815,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/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/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/manifest.rs b/crates/plugins/src/manifest.rs index 081c7acf6d..2606b34c2b 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -398,8 +398,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)] @@ -471,6 +475,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}"); 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..e0d6d8aa35 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -317,3 +317,44 @@ 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(rustix::fs::CWD, &path, rustix::fs::FileType::Fifo, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, 0).unwrap(); + 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 b26a33dff6..421d706ab7 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -145,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/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/patterns.rs b/crates/psd/src/patterns.rs index 7f845f525e..15558a700e 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"))?; @@ -407,12 +433,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 +451,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 +461,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(); @@ -532,6 +598,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 +836,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 +860,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/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 100% rename from crates/psd/tests/roundtrip.rs rename to crates/psd/tests/it/roundtrip.rs diff --git a/crates/psd/tests/swatch_malformed.rs b/crates/psd/tests/it/swatch_malformed.rs similarity index 100% rename from crates/psd/tests/swatch_malformed.rs rename to crates/psd/tests/it/swatch_malformed.rs diff --git a/crates/psd/tests/tree.rs b/crates/psd/tests/it/tree.rs similarity index 100% rename from crates/psd/tests/tree.rs rename to crates/psd/tests/it/tree.rs diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index f0d9b63b78..f3778ccbe5 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -476,30 +476,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; } } @@ -725,7 +732,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 })?; @@ -883,6 +892,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()); @@ -897,6 +910,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)); @@ -977,7 +998,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"))] { @@ -1091,7 +1119,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) } } @@ -1828,6 +1857,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| { @@ -1894,6 +1954,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/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/src/sensor.rs b/crates/raw/src/sensor.rs index cab5ce2a05..a7c512a211 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -259,15 +259,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| { @@ -306,31 +299,8 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou 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]; @@ -347,6 +317,29 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou Ok(Plane { width, height, samples, bits, data }) } +/// 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> { let mut out = Vec::new(); if let (Some(tw), Some(th)) = (t.tag_uint(ifd, tag::TILE_WIDTH), t.tag_uint(ifd, tag::TILE_LENGTH)) { @@ -390,6 +383,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, @@ -493,6 +517,63 @@ fn unpack(src: &[u8], w: usize, rows: usize, samples: usize, bits: u32, le: bool mod tests { use super::*; + /// #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/tiff.rs b/crates/raw/src/tiff.rs index ca190a92f0..df695e3346 100644 --- a/crates/raw/src/tiff.rs +++ b/crates/raw/src/tiff.rs @@ -305,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)) } @@ -345,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). 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 100% rename from crates/raw/tests/dng.rs rename to crates/raw/tests/it/dng.rs diff --git a/crates/raw/tests/hostile.rs b/crates/raw/tests/it/hostile.rs similarity index 100% rename from crates/raw/tests/hostile.rs rename to crates/raw/tests/it/hostile.rs 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/nikon_d4_depth.rs b/crates/raw/tests/it/nikon_d4_depth.rs similarity index 100% rename from crates/raw/tests/nikon_d4_depth.rs rename to crates/raw/tests/it/nikon_d4_depth.rs 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/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..c0e632d983 100644 --- a/crates/text/src/cjk.rs +++ b/crates/text/src/cjk.rs @@ -509,19 +509,48 @@ pub fn mac_pingfang() -> Option { None } +/// Maximum collection faces and cumulative `name` records inspected by [`face_index_for_family`]. +const MAX_COLLECTION_FACES: usize = 256; +const MAX_NAME_RECORDS: usize = 8192; + /// 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}; + use skrifa::{ + MetadataProvider, + raw::{FileRef, types::Tag}, + 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 mut seen_faces = std::collections::HashSet::new(); + let mut seen_names = std::collections::HashSet::new(); + let mut budget = MAX_NAME_RECORDS; + 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 }; - 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; + 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; + } + for id in [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME] { + for s in f.localized_strings(id) { + let Some(rem) = budget.checked_sub(1) else { + return 0; + }; + budget = rem; + if s.chars().eq(family.chars()) { + return i as u32; + } + } } } 0 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 8b8bcd419c..a1eda9b801 100644 --- a/crates/text/src/font_names_tests.rs +++ b/crates/text/src/font_names_tests.rs @@ -251,3 +251,220 @@ fn craft_font_collections_keep_their_face_families() { 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 893fd29ed8..a0b64f4e3b 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -298,6 +298,9 @@ impl FontDb { /// 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 { @@ -480,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) @@ -511,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 { @@ -551,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 29a2414e8c..a19588439f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,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; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 9b50b13606..642050d916 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -299,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 { @@ -312,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( @@ -430,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 @@ -466,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]> { @@ -475,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 { @@ -492,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 @@ -535,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() @@ -557,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; @@ -578,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(); @@ -594,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. @@ -614,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. @@ -698,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 { @@ -1294,6 +1339,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 }; @@ -1305,6 +1356,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 { @@ -1332,7 +1404,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, "1")?; + 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) @@ -1374,6 +1447,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()); @@ -1382,7 +1456,11 @@ 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, "1")? } else { runs_to_length(runs, total, "1")? }; + 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) @@ -1409,6 +1487,7 @@ fn td_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 (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()); @@ -1424,9 +1503,9 @@ fn td_edited_object(object: &E, text: &str) -> Option { 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")? + runs_per(runs, ¶graphs, "Length", &mut nodes, &mut bytes)? } else { - runs_to_length(runs, total, "Length")? + runs_to_length(runs, total, "Length", &mut nodes, &mut bytes)? } } _ => return None, @@ -1437,13 +1516,18 @@ fn td_edited_object(object: &E, text: &str) -> Option { } /// `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`. -fn runs_per(runs: &[E], lens: &[usize], len_key: &str) -> Option> { +/// 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(); + 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) }) @@ -1452,14 +1536,18 @@ fn runs_per(runs: &[E], lens: &[usize], len_key: &str) -> 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 or a length isn't a count. -fn runs_to_length(runs: &[E], total: usize, len_key: &str) -> Option> { +/// 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(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; } @@ -1467,7 +1555,13 @@ fn runs_to_length(runs: &[E], total: usize, len_key: &str) -> 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() @@ -1651,12 +1745,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 { + 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 { @@ -1769,9 +1869,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| { @@ -1804,6 +1909,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::*; @@ -1831,17 +1956,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. @@ -1849,14 +1974,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 index 032792f9be..777ac2aa4d 100644 --- a/crates/text/src/psd_path.rs +++ b/crates/text/src/psd_path.rs @@ -290,6 +290,13 @@ pub(crate) fn update_frame(entry: &mut E, shape: &TextShape) { _ 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(), + } + } } } 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/render.rs b/crates/text/src/render.rs index fe32982d9d..c7cd3960b4 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -21,6 +21,8 @@ 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 @@ -135,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]; @@ -287,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(); @@ -302,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); @@ -309,10 +380,14 @@ 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; - let bold = layout.glyphs.iter().any(|g| is_bold(layout, g)); - if !fits_raster_budget(w, h, stride, format.sample, bold, MAX_RASTER_BYTES) { + 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); } // Premultiplied accumulation. @@ -320,26 +395,45 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo 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 mut colors: Vec = Vec::new(); - for st in &layout.styles { - if !colors.contains(&st.color) { - colors.push(st.color); + 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; } - } - for c in &colors { - let Some(mut cov) = Coverage::try_new(w, h) else { + 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); }; - draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain); + 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 bold { + if color_bold { for k in 0..BOLD_OFFSETS.len() { - let Some(mut bc) = Coverage::try_new(w, h) else { + 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)); + draw_pass(layout, &xf, &mut bc, Some(&c), warp, Pass::Bold(k)); let Some(bold_cov) = bc.finish() else { return nothing(format); }; @@ -348,9 +442,9 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } } } - 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); @@ -359,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; @@ -379,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. 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 83f699fc85..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; @@ -1650,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] @@ -2666,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/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 3a461040f6..7fa07a10bc 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -11,6 +11,7 @@ //! `--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 @@ -38,6 +39,7 @@ 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(); @@ -83,6 +85,14 @@ fn main() { 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 3e946b0d72..946a246d62 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -98,7 +98,7 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result) -> Result { let (action, from) = match actions_cmds::playback_plan(&app.session, params) { @@ -129,41 +129,46 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) failed = Some(json!({"step": i, "id": id, "error": format!("action playback exceeded the {}-step limit", actions_cmds::MAX_PLAYBACK_STEPS)})); break; } + // The gate judges the step before its dialog opens: a denied step neither runs nor + // pauses. + if let Some(auth) = app.session.authorize + && let Err(e) = auth(id, p) + { + failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); + break; + } // The dialog toggle: show the step's dialog with its recorded values and continue // from the next step when it is confirmed (`resume`). Agents play headlessly. if action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id)) { let label = label_of(id); let dialog = crate::dialogs::open_command_dialog(app, id, &label); + let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); if let Some(d) = app.ui.dialog_mut(dialog) { if let Some(o) = p.as_object() { - d.fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + // `__` keys are the dialog's own markers (`params_of` drops them), not step values. + d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); } - d.fields.insert(RESUME.into(), json!({"action": action.name, "from": i + 1})); + d.fields.insert(RESUME.into(), json!(token)); } paused = Some(json!({"step": i, "id": id, "dialog": dialog})); break; } let target = app.session.active().map(|d| d.doc.id); - let result = (|| { - if let Some(auth) = app.session.authorize { - auth(id, p).map_err(|e| e.to_string())?; - } - if id == "actions.play" { - play(app, p) - } else if actions_cmds::shell_save_command(id) { - // Write synchronously, so the next step doesn't meet a document locked by a - // background save. - let background = std::mem::replace(&mut app.background_jobs, false); - let r = app.replay_save(id, p); - app.background_jobs = background; - r - } else if actions_cmds::shell_view_command(id) { - app.sync_views(); - app.run(id, p.clone()) - } else { - app.session.execute(id, p.clone()).map_err(|e| e.to_string()) - } - })(); + let result = if id == "actions.play" { + play(app, p) + } else if actions_cmds::shell_save_command(id) { + // Write synchronously, so the next step doesn't meet a document locked by a + // background save. + let background = std::mem::replace(&mut app.background_jobs, false); + let r = app.replay_save(id, p); + app.background_jobs = background; + r + } else if actions_cmds::shell_view_command(id) { + app.sync_views(); + app.run(id, p.clone()) + } else { + app.session.execute(id, p.clone()).map_err(|e| e.to_string()) + }; match result { Ok(value) => { if let Some(error) = actions_cmds::nested_failure(id, &value).or_else(|| actions_cmds::save_target_closed(&app.session, id, target)) { @@ -186,13 +191,14 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) actions_cmds::finish_playback(&mut app.session, None, &action, from); match paused.as_ref().and_then(|p| p.get("dialog")).and_then(Value::as_u64).and_then(|d| app.ui.dialog_mut(d)) { // Paused for a dialog: the call goes on after it, so recording stays suspended. - Some(d) if failed.is_none() => match (owner, d.fields.get_mut(RESUME)) { - (Some(owner), Some(Value::Object(r))) => { - r.insert(RECORD.into(), owner); + Some(d) if failed.is_none() => { + let entry = d.fields.get(RESUME).and_then(Value::as_u64).and_then(|t| app.paused_playbacks.paused.get_mut(&t)); + match (owner, entry) { + (Some(owner), Some(p)) => p.record = Some(owner), + // No continuation to carry the recording: the call ends here. + (owner, _) => restore_recording(app, owner.as_ref()), } - // 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) { @@ -478,16 +484,9 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) { let Some(owner) = owner else { return }; @@ -511,9 +510,9 @@ fn restore_recording(app: &mut PhotocraftApp, owner: Option<&Value>) { } } -/// Whether a step dialog holds a suspended recording. +/// Whether a paused playback holds a suspended recording. fn suspended(app: &PhotocraftApp) -> bool { - app.ui.dialogs.iter().any(|d| d.fields.get(RESUME).and_then(|r| r.get(RECORD)).is_some()) + 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 @@ -529,13 +528,58 @@ pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), Strin /// A step dialog's command failed: the action ends there. pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map) { - restore_recording(app, fields.get(RESUME).and_then(|r| r.get(RECORD))); + 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 owner = r.get(RECORD).cloned(); + 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) @@ -547,8 +591,12 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) 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_owned(app, ¶ms, owner) { + 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(()) @@ -1687,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}; @@ -1821,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); @@ -1939,6 +2055,18 @@ mod tests { 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(); @@ -1982,6 +2110,178 @@ mod tests { 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..1c45da190b 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -279,6 +279,33 @@ 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). fn isolated(app: &PhotocraftApp) -> Option { @@ -602,6 +629,84 @@ mod tests { } } + /// #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::new(("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"] { + assert!(wide.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} 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::new(("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::new(("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::new(("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::new(("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 = @@ -1075,7 +1180,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 +1188,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`. diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 877525ffb8..f646ddd452 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -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); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index d8c6e7953a..7f669186bb 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -206,6 +206,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] diff --git a/crates/ui-egui/src/apply_color.rs b/crates/ui-egui/src/apply_color.rs new file mode 100644 index 0000000000..9c4e3d7f7b --- /dev/null +++ b/crates/ui-egui/src/apply_color.rs @@ -0,0 +1,141 @@ +//! 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. +//! +//! 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::new("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) { + // 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. + let (do_texts, do_shapes) = match tool { + 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..b07d731775 --- /dev/null +++ b/crates/ui-egui/src/apply_color_tests.rs @@ -0,0 +1,149 @@ +//! 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"); +} diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index f0b6cc906b..1ea52bdb72 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1378,7 +1378,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/camera_raw_scope_ui.rs b/crates/ui-egui/src/camera_raw_scope_ui.rs index 9cb5233d00..4e93b72df9 100644 --- a/crates/ui-egui/src/camera_raw_scope_ui.rs +++ b/crates/ui-egui/src/camera_raw_scope_ui.rs @@ -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 cffe3fa026..f78d467b7c 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -26,6 +26,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"]; @@ -292,11 +296,19 @@ impl ProxyJob { } photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float); 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); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7f9db071c7..ab3e6939a7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -202,6 +202,9 @@ 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), @@ -226,6 +229,10 @@ pub struct DetailTexture { 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])>, } @@ -266,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, } @@ -283,6 +293,7 @@ impl Drag { released: egui::Modifiers::NONE, reposition: false, sel_move: None, + sel_axis: None, lasso: None, } } @@ -791,11 +802,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 { @@ -803,27 +816,40 @@ 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()?; @@ -910,16 +936,30 @@ 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 => { + if rest.is_empty() || live.push(&rest).is_ok() { + let _ = app.session.offer_live_retouch(live); + } + } + EngineStroke::Dab(mut live) if dab => { + if rest.is_empty() || live.push(&rest).is_ok() { + let _ = app.session.offer_live_dab(live); + } + } + _ => {} } } @@ -1050,14 +1090,52 @@ impl ViewXform { let v = app.ui.views.get(app.session.active_index()?)?; 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(), - }) + 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) { @@ -1385,6 +1463,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); @@ -1611,6 +1690,27 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, 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( @@ -1626,6 +1726,23 @@ pub(crate) fn ensure_texture_in_view( (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) @@ -1636,7 +1753,7 @@ pub(crate) fn ensure_texture_in_view( } 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))?; @@ -1656,6 +1773,7 @@ pub(crate) fn ensure_texture_in_view( on_gpu: false, tex_revision: 0, tex_preview_key: 0, + texture_opaque: false, grid: None, detail: Vec::new(), }); @@ -1671,7 +1789,11 @@ pub(crate) fn ensure_texture_in_view( 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); } @@ -1680,6 +1802,7 @@ pub(crate) fn ensure_texture_in_view( 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))), @@ -1693,10 +1816,10 @@ pub(crate) fn ensure_texture_in_view( 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, detail: None }) }; + 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, detail: None }); + 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); @@ -1704,7 +1827,7 @@ pub(crate) fn ensure_texture_in_view( 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, detail: None }) }; + 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)); @@ -1716,7 +1839,7 @@ pub(crate) fn ensure_texture_in_view( if cache.detail.len() > MAX_DETAIL_VIEWS { cache.detail.remove(0); } - Some(CpuTextures { texture, scale, detail: Some(shown) }) + Some(CpuTextures { texture, scale, bounds, detail: Some(shown) }) } /// Most CPU canvas views of one document on one display that keep a detail texture. @@ -1973,6 +2096,7 @@ 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(), }); @@ -3476,7 +3600,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if view.zoom_animating { ctx.request_repaint(); } - let xf = ViewXform { zoom: display_view.zoom / ppp, center: display_view.center, ..target_xf }; + 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); @@ -3497,6 +3621,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); @@ -3530,8 +3659,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: { @@ -3554,7 +3683,7 @@ 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); @@ -3575,39 +3704,60 @@ 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); - 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 rotated = view.rotation.abs() > 0.05; - let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square)); // 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::new(("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); - paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex); + // 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 { + 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)); + // 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); } - for &c in &pattern { - paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex); + // 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); @@ -3756,7 +3906,7 @@ 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, &display_view, xf)) }; let img_rect = xf.doc_rect(doc.bounds()); @@ -4414,11 +4564,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: display_view.center = view.center; } display_view.rotation = view.rotation; - ViewXform { center: display_view.center, rotation: display_view.rotation, ..xf } + ViewXform { center: display_view.center, rotation: display_view.rotation, ..xf }.pixel_aligned_for(app, ppp) } else { xf }; - let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, 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. @@ -4465,7 +4615,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); @@ -4568,6 +4718,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, @@ -4693,19 +4853,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Photoshop's Move tool keeps the standard arrow pointer (#5646): the movable // layer under it is marked by the hover outline, not by a different cursor. Tool::Move => egui::CursorIcon::Default, - // Over a path it can follow, the Type tool's path-type pointer (Photoshop). - Tool::Type - if app.drag.is_none() && { - let [x, y] = xf.to_doc(p); - crate::type_tool::path_at(app, x, y).is_some() - } => - { - crate::type_tool::path_cursor(&painter, p) - } // Dragging is handled with the pan itself, above. 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 => { @@ -4828,10 +4983,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 { @@ -4874,22 +5029,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); } @@ -4897,7 +5057,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); @@ -4947,9 +5107,9 @@ fn paint_cpu_detail( 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, 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)]; +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)); } @@ -5325,15 +5485,18 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option