diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ced2757cd4..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 @@ -420,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: @@ -434,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/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/Cargo.lock b/Cargo.lock index 97f6aca6cb..69a1e38e9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4638,7 +4638,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.13.0" +version = "0.15.0" dependencies = [ "arboard", "async-io", @@ -4685,7 +4685,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.13.0" +version = "0.15.0" dependencies = [ "crc32fast", "flate2", @@ -4695,11 +4695,12 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", + "photocraft-testkit", "rayon", "rustfft", "serde", @@ -4708,7 +4709,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.13.0" +version = "0.15.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4731,7 +4732,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4746,7 +4747,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.13.0" +version = "0.15.0" dependencies = [ "moxcms", "rayon", @@ -4754,7 +4755,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.13.0" +version = "0.15.0" dependencies = [ "exr", "flate2", @@ -4775,7 +4776,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-cms", "serde", @@ -4783,7 +4784,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.13.0" +version = "0.15.0" dependencies = [ "criterion", "photocraft-cms", @@ -4799,7 +4800,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4810,7 +4811,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.13.0" +version = "0.15.0" dependencies = [ "blake3", "chrono", @@ -4834,6 +4835,7 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "photocraft-raw", + "photocraft-testkit", "photocraft-text", "photocraft-vector", "rayon", @@ -4850,7 +4852,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.13.0" +version = "0.15.0" dependencies = [ "blake3", "crc32fast", @@ -4873,14 +4875,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.13.0" +version = "0.15.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.13.0" +version = "0.15.0" dependencies = [ "half", "log", @@ -4898,14 +4900,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.13.0" +version = "0.15.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.13.0" +version = "0.15.0" dependencies = [ "blake3", "craft-docpath", @@ -4924,6 +4926,7 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "photocraft-raw", + "photocraft-testkit", "photocraft-text", "photocraft-vector", "proptest", @@ -4935,18 +4938,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.13.0" +version = "0.15.0" [[package]] name = "photocraft-ops" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4957,12 +4960,13 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", "rayon", + "rustix 1.1.5", "serde", "serde_json", "thiserror 2.0.21", @@ -4972,7 +4976,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.13.0" +version = "0.15.0" dependencies = [ "flate2", "photocraft-psd", @@ -4983,7 +4987,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4994,7 +4998,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.13.0" +version = "0.15.0" dependencies = [ "flate2", "image", @@ -5009,7 +5013,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.13.0" +version = "0.15.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -5021,7 +5025,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.13.0" +version = "0.15.0" dependencies = [ "memory-stats", "serde_json", @@ -5029,7 +5033,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.13.0" +version = "0.15.0" dependencies = [ "flate2", "parley", @@ -5044,7 +5048,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.13.0" +version = "0.15.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5087,7 +5091,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.13.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5098,7 +5102,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.13.0" +version = "0.15.0" dependencies = [ "eframe", "egui", @@ -5120,7 +5124,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.13.0" +version = "0.15.0" dependencies = [ "flate2", "js-sys", @@ -8762,7 +8766,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.13.0" +version = "0.15.0" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1fba71bc79..154ca201f6 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.13.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -72,15 +72,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/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 14d12bcb94..20e64fa046 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -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/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 880b7236d1..0250cc4f77 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -15,6 +15,8 @@ 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; /// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. const MAX_ZSTD_WINDOW: u64 = 64 << 20; @@ -164,7 +166,7 @@ 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 seen = HashSet::new(); let mut next = header.fat_offset; @@ -222,7 +224,7 @@ 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")), @@ -235,6 +237,10 @@ impl<'a> Archive<'a> { // 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 +254,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 }) diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 6825abd511..c62c206f58 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))); @@ -152,6 +162,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 +272,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..1d8e5918be 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -288,6 +288,8 @@ 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; /// Read a document: archive, `doc.dat` and the model. pub fn read(bytes: &[u8], limits: Limits) -> Result { @@ -304,6 +306,7 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, child_slots: usize, + 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 +323,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { warnings: BTreeMap::new(), nodes: 0, child_slots: 0, + name_bytes: 0, active: HashSet::new(), geometry_budget: crate::geometry::Budget::default(), pixels_left: limits.max_pixel_bytes, @@ -394,10 +398,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 +420,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Ok((out, mask)) } + /// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`]. + 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 +447,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 +476,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…)"); } diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index bb1ecc112f..f2b99c195e 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -181,24 +181,30 @@ 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 }); 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 a5517653de..c932aac399 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -302,6 +302,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..3b09c75124 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 } 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/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/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/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 997ef8f05f..f804abae51 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -561,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)), @@ -571,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), @@ -698,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), @@ -719,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))), @@ -999,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, } } @@ -1096,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; } } diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index ee678d1aa9..9ebb3115c9 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -209,11 +209,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 } } @@ -1122,6 +1131,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. 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/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/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/cms/README.md b/crates/cms/README.md index 9109f48ed2..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 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/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/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index cf1c9fca43..14d8f8ffec 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -137,32 +137,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 +179,7 @@ fn data_end(b: &[u8]) -> DataEnd { } } scan_data |= i > start; + scanned += u64::try_from(i - start).unwrap_or(0); } } } @@ -182,8 +190,12 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } +/// 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")); } @@ -204,6 +216,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 +454,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() { 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..42afce3140 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) { 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/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/it/pcx.rs b/crates/codecs/tests/it/pcx.rs index a5796473e6..792a41021c 100644 --- a/crates/codecs/tests/it/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/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 1eb1d67c65..bed4de7338 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -151,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 { @@ -417,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( @@ -1754,14 +1774,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), @@ -2260,12 +2300,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/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/tests.rs b/crates/compose/src/tests.rs index f6989b4291..17c19d8d9f 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1761,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); @@ -2350,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. 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/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/engine/Cargo.toml b/crates/engine/Cargo.toml index 0a12ff7ff5..fdf2ac0a00 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -62,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/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 2bd6657e68..75fbff8665 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3154,13 +3154,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) { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 33523d9786..929182309d 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -2654,6 +2654,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 diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 2256a76cda..800ff954e0 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -358,7 +358,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/lib.rs b/crates/engine/src/lib.rs index 29d1234d6a..4a3521073f 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; diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index 5af30d0e76..3b1c25c21e 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -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}"); 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/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/tests/it/precheck.rs b/crates/engine/tests/it/precheck.rs index f549fbb8c8..b737ddea5a 100644 --- a/crates/engine/tests/it/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/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 853a11293d..d48cf7d9a6 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -328,6 +328,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() { diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index fa3c9a4085..cb09e27cb8 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -20,7 +20,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 +167,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. pub fn list_recovery(recovery_dir: &Path) -> Vec { @@ -189,8 +254,9 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec { }; 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) + if e.file_type().is_ok_and(|ft| ft.is_file()) + && p.extension().is_some_and(|x| x == "json") + && let Some(bytes) = read_sidecar_bounded(&p, 1 << 20) && let Ok(info) = serde_json::from_slice::(&bytes) && sanitize(&info.key) == info.key && p.file_stem().is_some_and(|stem| *stem == *info.key) @@ -402,6 +468,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/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..18e6a9b432 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -598,7 +598,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; diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index d95e4b847f..acd422b32f 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() { diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index f22e1945b3..cbbd746195 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -581,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)); @@ -620,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; @@ -1066,8 +1081,29 @@ impl Warp { 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(), @@ -1590,4 +1626,38 @@ mod tests { // 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/src/plan.rs b/crates/gpu/src/plan.rs index 815b55517d..69e4b64852 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)) } @@ -1876,6 +1887,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()); diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index eb8c0b00d7..261c16481c 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -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/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/svg.rs b/crates/io/src/svg.rs index ac1bd27ffa..6bc256d4e0 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -28,6 +28,48 @@ 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. +fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option> { + match mime { + "image/png" => Some(png_dimensions(data)), + "image/webp" => Some(photocraft_codecs::probe_size(data).ok()), + "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") { + Some(photocraft_codecs::probe_size(data).ok()) + } 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 +181,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 +243,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 }) } @@ -1058,4 +1131,46 @@ 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 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/tests/it/svg.rs b/crates/io/tests/it/svg.rs index d1baf418fa..c71bb0ae4f 100644 --- a/crates/io/tests/it/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/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs index 382f45bc44..1bbe8e2f4b 100644 --- a/crates/io/tests/it/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -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/ipp/src/lib.rs b/crates/ipp/src/lib.rs index d0535a86ce..bbf6efffec 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. @@ -434,7 +436,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 +463,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 +491,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 }; diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs index 394ff758d1..518c9dfa39 100644 --- a/crates/ipp/src/tests.rs +++ b/crates/ipp/src/tests.rs @@ -429,10 +429,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 +459,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/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/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/patterns.rs b/crates/psd/src/patterns.rs index 7f845f525e..15d88f2ea8 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -56,6 +56,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 @@ -425,9 +431,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,11 +441,10 @@ 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) } @@ -729,6 +733,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 +757,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/raster/src/lib.rs b/crates/raster/src/lib.rs index 49806e521c..6ce0ea688d 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -732,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 })?; @@ -890,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()); @@ -904,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)); @@ -984,7 +998,11 @@ 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)); + if let Some(k) = memo_key { + built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(k, finished.clone()); + } + finished.map(|t| (dc, t)) }; #[cfg(not(target_arch = "wasm32"))] { @@ -1835,6 +1853,36 @@ 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; only the 64 border tiles (cut by the content's edge) are built one by one. + assert!(moved.tile_count() == 289 && distinct.len() <= 64 + 5, "{} 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| { 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/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/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/fonts.rs b/crates/text/src/fonts.rs index 893fd29ed8..9f43c705b1 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 { @@ -551,6 +554,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 061f2ac675..4936b33c63 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -606,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); } @@ -621,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 { @@ -684,8 +685,7 @@ impl Layouter { // (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 { - let si0 = style_at(prange.start); - (0..out.styles.len()) + (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)); diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 35a47558ee..13f3244801 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -473,15 +473,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 { @@ -490,9 +497,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 @@ -533,17 +546,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() @@ -555,15 +573,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; @@ -576,14 +598,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(); @@ -592,12 +614,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. @@ -612,26 +634,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. @@ -696,7 +703,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 { @@ -1277,6 +1284,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 }; @@ -1288,6 +1301,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 { @@ -1315,7 +1349,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) @@ -1360,6 +1395,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()); @@ -1368,7 +1404,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) @@ -1395,6 +1435,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()); @@ -1410,9 +1451,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, @@ -1423,13 +1464,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) }) @@ -1438,14 +1484,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; } @@ -1453,7 +1503,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() @@ -1799,6 +1855,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::*; @@ -1826,17 +1902,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. @@ -1844,11 +1920,11 @@ 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); } 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 963fe08c8e..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]; @@ -310,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); @@ -317,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. @@ -328,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); }; @@ -356,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); @@ -367,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; @@ -387,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/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 82432879db..3bbf36b3c2 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -1075,7 +1075,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 +1083,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/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/delete_layer_prompt.rs b/crates/ui-egui/src/delete_layer_prompt.rs index 7c1913db78..d67d299345 100644 --- a/crates/ui-egui/src/delete_layer_prompt.rs +++ b/crates/ui-egui/src/delete_layer_prompt.rs @@ -154,24 +154,4 @@ mod tests { assert_eq!(clear.shortcut, Some("Delete")); assert_eq!(delete.shortcut, None); } - - #[test] - fn confirmation_strings_are_translated_in_every_supported_catalog() { - for language in crate::i18n::LANGUAGES.iter().filter(|l| l.code != "en") { - let lang = crate::i18n::Lang::from_code(language.code).unwrap(); - for key in [ - "Delete layer ‘{name}’?", - "Delete {count} layers? {names}", - "Delete layer?", - "Don't show again", - "The document is closed", - "The document or selected layers changed. Please request deletion again.", - "Missing delete command", - "Invalid delete command", - "Layer deleted, but confirmation preference could not be saved: {error}", - ] { - assert_ne!(crate::i18n::tr(lang, key), key, "{}: {key}", language.code); - } - } - } } diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index f6f654afa0..263f3652bf 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -787,8 +787,11 @@ mod tests { assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]); let german = crate::i18n::Lang::from_code("de").unwrap(); crate::i18n::with_language(german, || { - assert_eq!(button_labels(false, &buttons), ["Ja (Y)", "Nein (N)", "Abbrechen (C)"]); - assert_eq!(button_labels(true, &buttons), ["Ja", "Nein", "Abbrechen"]); + let shown: Vec<&str> = buttons.iter().map(|(label, _)| crate::i18n::tr(german, label)).collect(); + assert_eq!(button_labels(true, &buttons), shown); + let labels = button_labels(false, &buttons); + let appended: Vec = shown.iter().zip(&buttons).map(|(text, (_, key))| format!("{text} ({})", key.map_or("", |k| k.name()))).collect(); + assert!(labels.iter().all(|label| label.starts_with('(')) || labels == appended, "{labels:?}"); }); } diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 6a959c38d7..90f70dd77e 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -385,15 +385,10 @@ mod tests { } #[test] - fn image_mode_depth_labels_match_sample_types_and_are_translated() { + fn image_mode_depth_labels_match_sample_types() { assert_eq!( DEPTHS.iter().map(|(sample, label, _)| (*sample, *label)).collect::>(), [(SampleType::U8, "8 Bits/Channel"), (SampleType::U16, "16 Bits/Channel"), (SampleType::F32, "32 Bits/Channel"),] ); - for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") { - for (_, label, id) in DEPTHS { - assert_ne!(crate::i18n::tr_id(lang, id, label), *label, "{}: {label}", lang.code()); - } - } } } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index e8c838ebc8..b611b9ac64 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1396,14 +1396,10 @@ mod tests { assert_eq!(source_label("centerX"), "Center X"); } - /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out, and translated. + /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out. #[test] fn black_point_compensation_is_spelled_out() { assert_eq!(source_label("bpc"), "Black Point Compensation"); - // English is the source: it has no catalog. - for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { - assert!(crate::i18n::has(lang, "Black Point Compensation"), "{lang:?}"); - } } fn key() -> FilterPreviewKey { @@ -1855,105 +1851,14 @@ mod tests { assert!(!has_dialog("filter.stylize.findEdges")); } - /// Generated filter and adjustment labels (param names, symbolic choices) and gallery - /// names that `lang` has no catalog row for. - fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet { - let mut missing = std::collections::BTreeSet::new(); - let mut check = |s: String| { - if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) { - missing.insert(s); - } - }; - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("filter.") || c.id.starts_with("image.adjustments.")) { - for p in parse_spec(c.params) { - if !p.key.chars().all(|c| c.is_ascii_alphanumeric()) || matches!(p.kind, Kind::Json) { - continue; - } - check(source_label(&p.key)); - if let Kind::Choice { options, .. } = &p.kind { - for choice in options { - // Only symbolic options are UI choices; registry docs also contain - // colour syntax and array notation, which are not translatable names. - if choice.chars().all(|c| c.is_ascii_alphanumeric()) { - check(source_label(choice)); - } - } - } - } - } - for f in photocraft_algo::GalleryFilter::ALL { - check(f.name().to_string()); - } - for cat in photocraft_algo::GALLERY_CATEGORIES { - check(cat.to_string()); - } - missing - } - + /// Select and Mask's Output To lists all six output targets. #[test] - fn korean_covers_generated_filter_options_and_gallery_names() { - let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap()); - assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}"); - } - - #[test] - fn italian_covers_generated_filter_options_and_gallery_names() { - let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap()); - assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}"); - } - - #[test] - fn spanish_covers_generated_filter_options_and_gallery_names() { - let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("es-ES").unwrap()); - assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}"); - } - - #[test] - fn russian_covers_generated_filter_options_and_gallery_names() { - let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap()); - assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}"); - } - - /// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their - /// params, choices and title in every UI language, not just Korean. - #[test] - fn select_dialogs_are_translated_in_every_language() { - let mut missing = std::collections::BTreeMap::<&str, std::collections::BTreeSet>::new(); - for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { - let mut check = |s: String| { - if !crate::i18n::has(lang, &s) { - missing.entry(lang.code()).or_default().insert(s); - } - }; - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("select.") && has_dialog(c.id)) { - let title = c.label.trim_end_matches('…'); - if !crate::i18n::has(lang, title) { - check(format!("{title}…")); - } - for p in parse_spec(c.params) { - check(source_label(&p.key)); - if let Kind::Choice { options: choices, .. } = p.kind { - choices.iter().for_each(|choice| check(source_label(choice))); - } - } - } - } - assert!(missing.is_empty(), "untranslated Select dialog labels: {missing:#?}"); - } - - /// Select and Mask's Output To lists all six of Photoshop's targets, each translated. - #[test] - fn select_and_mask_output_choices_are_translated() { + fn select_and_mask_output_lists_all_six_targets() { let spec = photocraft_engine::commands::find("select.refineEdge").unwrap().params; let p = parse_spec(spec).into_iter().find(|p| p.key == "output").unwrap(); let Kind::Choice { options: choices, .. } = p.kind else { panic!("output is not a choice") }; let labels: Vec = choices.iter().map(|c| source_label(c)).collect(); assert_eq!(labels, ["Selection", "Layer Mask", "New Layer", "New Layer With Mask", "New Document", "New Document With Mask"]); - for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { - for label in &labels { - assert!(crate::i18n::has(lang, label), "{}: untranslated {label}", lang.code()); - } - } } #[test] diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index d9608a539b..cd2bb7bc4d 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -297,8 +297,11 @@ mod tests { assert_eq!(result["info"]["lines"], json!(english)); assert_eq!(crate::i18n::current().code(), "cs", "diagnostics restore the drawing language"); let screen = system_info_ui(&app); - assert!(screen.iter().any(|line| line == "Grafický adaptér: Test GPU"), "{screen:?}"); - assert!(screen.iter().any(|line| line.starts_with("Profil monitoru:")), "{screen:?}"); + let cs = crate::i18n::current(); + let adapter = crate::i18n::fmt(crate::i18n::tr(cs, "Graphics adapter: {adapter}"), &[("adapter", "Test GPU")]); + assert!(screen.contains(&adapter), "{screen:?}"); + let profile = crate::i18n::tr(cs, "Monitor profile: {profile}").split('{').next().unwrap_or_default(); + assert!(screen.iter().any(|line| line.starts_with(profile)), "{screen:?}"); assert!(english.iter().any(|line| line == "GPU backend preference: auto")); }); } @@ -313,8 +316,11 @@ mod tests { app.perf.gpu_info.preference = "auto".into(); app.perf.gpu_info.selected = "auto".into(); assert_eq!(system_info_ui(&app), empty); - assert!(empty.iter().any(|line| line == "Grafický adaptér: auto")); - assert!(empty.iter().any(|line| line == "Preferovaný backend GPU: automaticky"), "{empty:?}"); + let cs = crate::i18n::current(); + let adapter = crate::i18n::fmt(crate::i18n::tr(cs, "Graphics adapter: {adapter}"), &[("adapter", "auto")]); + assert!(empty.contains(&adapter), "{empty:?}"); + let preference = crate::i18n::fmt(crate::i18n::tr(cs, "GPU backend preference: {backend}"), &[("backend", crate::i18n::tr(cs, "auto"))]); + assert!(empty.contains(&preference), "{empty:?}"); assert_eq!(app.perf.gpu_info.preference, "auto"); assert_eq!(app.perf.gpu_info.selected, "auto"); }); diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs index cf95f18a01..da77f9d2fb 100644 --- a/crates/ui-egui/src/history_menu_ui.rs +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -187,17 +187,6 @@ mod tests { assert_eq!(states(&h).0, steps.0 - 1, "the current state is deleted"); } - /// The labels aren't `tl!` literals, so the catalog scan can't see them: every language that - /// claims complete menus must still translate them. - #[test] - fn menu_labels_are_translated() { - for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) { - for &(label, _) in ITEMS.iter().filter(|(l, _)| *l != "---") { - assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code()); - } - } - } - #[test] fn without_a_document_only_close_is_enabled() { let h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e11619dec..e12ed4f59e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -822,6 +822,7 @@ Split Warp Vertically Rozdělit deformaci svisle Statistics… Statistika… Refine Strokes Tahy zpřesnění + Edits Úpravy @plural {n} item|{n} items {n} položka|{n} položky|{n} položek Auto Automaticky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c31df19d25..bf290176a8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -811,6 +811,7 @@ Split Warp Vertically Verkrümmung vertikal teilen Statistics… Statistiken… Refine Strokes Verfeinerungsstriche + Edits Bearbeitungen @plural {n} item|{n} items {n} Element|{n} Elemente Auto Automatisch (print to the printer) (an den Drucker senden) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 090d9ab744..6db59bc44a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1597,6 +1597,7 @@ Reset Alignment Επαναφορά στοίχισης Centre the gradient (offset 0, 0) Κεντράρισμα διαβάθμισης (μετατόπιση 0, 0) Refine Strokes Πινελιές βελτίωσης + Edits Επεξεργασίες # Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Επεξεργασία διαβάθμισης Gradient picker Επιλογή διαβάθμισης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a5b1eb8ef9..ac9d834020 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -819,6 +819,7 @@ Split Warp Vertically Dividir deformación verticalmente Statistics… Estadísticas… Refine Strokes Trazos de perfeccionamiento + Edits Ediciones @plural {n} item|{n} items {n} elemento|{n} elementos Auto Automático diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c7567a293a..90c1a1ca2e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -806,6 +806,7 @@ Split Warp Vertically Diviser la déformation verticalement Statistics… Statistiques… Refine Strokes Traits d'affinement + Edits Modifications @plural {n} item|{n} items {n} élément|{n} éléments Auto Auto (print to the printer) (imprimer sur l'imprimante) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index bdd224c440..34d749f777 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -502,6 +502,7 @@ cameraRaw Yellow पीला Output Output Refine Edge… Refine Edge… Refine Strokes Refine Strokes + Edits संपादन Selection Selection Shift Edge Shift Edge Smart Radius Smart Radius diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c079c3d82c..1733e0ca5c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1637,6 +1637,7 @@ fontWeight Thin Hajszálvékony Refine Finomítás Refine Edge Brush (R) Élfinomító ecset (R) Refine Strokes Finomító ecsetvonások + Edits Szerkesztések Reflect Visszaverődés Reflected Tükrözött Rename Channel… Csatorna átnevezése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0b087114c7..05b77f764e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -799,6 +799,7 @@ Split Warp Vertically Bagi Warp Vertikal Statistics… Statistik… Refine Strokes Goresan penyempurnaan + Edits Suntingan # Counted status and panel messages @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} swatch diekspor ke {file} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} swatch diimpor dari {file} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d524e37fcf..da6cce3d8f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -813,6 +813,7 @@ Split Warp Vertically Dividi altera verticalmente Statistics… Statistiche… Refine Strokes Tracce di perfezionamento + Edits Modifiche @plural {n} item|{n} items {n} elemento|{n} elementi Auto Automatico diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 653f41bf2d..5ac971b867 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -816,6 +816,7 @@ Split Warp Vertically ワープを垂直方向に分割 Statistics… 統計情報… Refine Strokes 調整ストローク + Edits 編集 @plural {n} item|{n} items {n} 件 Auto 自動 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0c5e31cd67..3a4dc62a46 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -793,6 +793,7 @@ Split Warp Vertically 뒤틀기 세로로 분할 Statistics… 통계… Refine Strokes 다듬기 획 + Edits 편집 # Counted status and panel messages @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 색상 견본 {n}개를 {file}(으)로 내보냈습니다 @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}에서 색상 견본 {n}개를 가져왔습니다 diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index e2150247b9..81639fa8d2 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -41,9 +41,10 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() { with_language(Lang::EN, || { let h = harness(); let layout = crate::native_menu::photocraft_layout(&crate::menus::menu_items(h.state()), lang("uk"), "uk"); - for (id, label) in [("help.about", "Про PhotoCraft"), (crate::native_menu::HIDE, "Приховати PhotoCraft"), ("file.exit", "Вийти з PhotoCraft")] - { - assert_eq!(layout.bar.find(id).map(|item| item.label.as_str()), Some(label), "{id}"); + for (id, template) in [("help.about", "About {app}"), (crate::native_menu::HIDE, "Hide {app}"), ("file.exit", "Quit {app}")] { + let label = fmt(tr(lang("uk"), template), &[("app", "PhotoCraft")]); + assert!(label.contains("PhotoCraft"), "{id}: {label}"); + assert_eq!(layout.bar.find(id).map(|item| item.label.as_str()), Some(label.as_str()), "{id}"); } assert_eq!(layout.bar.find("app.language.uk").map(|item| (item.label.as_str(), item.checked)), Some(("Українська", Some(true)))); }); @@ -52,29 +53,21 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() { #[test] fn ukrainian_color_picker_titles_draw_in_the_selected_language() { with_language(Lang::EN, || { - for (target, expected) in [("foreground", "Вибір кольору (основний колір)"), ("background", "Вибір кольору (колір тла)")] - { + for (target, title) in [("foreground", "Color Picker (Foreground Color)"), ("background", "Color Picker (Background Color)")] { let mut h = harness(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); crate::color_picker_ui::open(h.state_mut(), target); h.run_steps(4); - let text = drawn_text(&h); - assert!(text.iter().any(|text| text == expected), "missing title {expected}"); - assert!(!text.iter().any(|text| text.starts_with("Color Picker")), "no English picker title"); + let expected = tr(lang("uk"), title); + assert!(drawn_text(&h).iter().any(|text| text == expected), "missing title {expected}"); } - assert_eq!(tr(lang("uk"), "Color Picker (Custom Canvas Color)"), "Вибір кольору (власний колір полотна)"); }); } #[test] -fn ukrainian_rasterize_prompts_explain_the_loss_of_editable_content() { +fn ukrainian_rasterize_prompt_draws_in_the_selected_language() { use crate::rasterize_prompt::Kind; - for kind in Kind::ALL { - let translated = tr(lang("uk"), kind.message()); - assert_ne!(translated, kind.message(), "{kind:?}: the warning must be translated"); - assert!(translated.contains("більше не можна буде редагувати"), "{kind:?}: explain the loss of editability"); - } with_language(Lang::EN, || { let mut h = harness(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); @@ -85,29 +78,17 @@ fn ukrainian_rasterize_prompts_explain_the_loss_of_editable_content() { let expected = tr(lang("uk"), Kind::Type.message()); let text = drawn_text(&h); assert!(text.iter().any(|text| text == expected), "the actual dialog draws the Ukrainian warning"); - assert!(!text.iter().any(|text| text == Kind::Type.message())); }); } -#[test] -fn french_and_korean_plural_messages_keep_placeholders() { - for (n, expected) in [(0, "0 élément"), (1, "1 élément"), (2, "2 éléments"), (21, "21 éléments")] { - assert_eq!(trn(lang("fr"), n, "{n} item", "{n} items"), expected); - } - for n in [0, 1, 2, 21, u64::MAX] { - assert_eq!(trn(lang("ko"), n, "{n} item", "{n} items"), format!("항목 {n}개")); - } - assert_eq!(fmt(tr(lang("ko"), "Version {version}"), &[("version", "0.2.0")]), "버전 0.2.0"); - assert_eq!(tr(lang("fr"), "unknown translation"), "unknown translation"); -} - #[test] fn scoped_preview_restores_language_even_after_a_panic() { with_language(Lang::EN, || { with_language(lang("fr"), || { - assert_eq!(t("Layer"), "Calque"); - with_language(lang("ko"), || assert_eq!(t("Layer"), "레이어")); - assert_eq!(t("Layer"), "Calque"); + assert_eq!(current(), lang("fr")); + assert_eq!(t("Layer"), tr(lang("fr"), "Layer")); + with_language(lang("ko"), || assert_eq!(current(), lang("ko"))); + assert_eq!(current(), lang("fr")); }); let result = std::panic::catch_unwind(|| with_language(lang("ja"), || panic!("test unwind"))); assert!(result.is_err()); @@ -120,20 +101,15 @@ fn drawing_languages_are_isolated_between_threads() { with_language(lang("fr"), || { let thread = std::thread::spawn(|| { assert_eq!(current(), Lang::EN); - with_language(lang("ko"), || assert_eq!(t("Layer"), "레이어")); + with_language(lang("ko"), || assert_eq!(t("Layer"), tr(lang("ko"), "Layer"))); }); thread.join().expect("language thread"); - assert_eq!(t("Layer"), "Calque"); + assert_eq!(current(), lang("fr")); }); } #[test] -fn dynamic_brush_sections_have_translations_and_draw_in_the_selected_language() { - for language in Lang::all().filter(|language| language.complete_menus()) { - for (name, _) in crate::brush_panel::SECTIONS { - assert!(has(language, name), "{}: untranslated brush section {name}", language.code()); - } - } +fn dynamic_brush_sections_draw_in_the_selected_language() { with_language(Lang::EN, || { let mut h = harness(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "zh-hans"})).expect("language"); @@ -141,21 +117,13 @@ fn dynamic_brush_sections_have_translations_and_draw_in_the_selected_language() h.run_steps(4); let text = drawn_text(&h); // The section row is the only place the name is drawn: the options pane has no heading (#3477). - assert_eq!(text.iter().filter(|text| text.as_str() == "画笔笔尖形状").count(), 1, "the section row must be translated"); - assert!(!text.iter().any(|text| text == "Brush Tip Shape")); + let expected = tr(lang("zh-hans"), "Brush Tip Shape"); + assert_eq!(text.iter().filter(|text| *text == expected).count(), 1, "the section row must be translated"); }); } #[test] -fn fill_dialog_and_contour_choices_have_translations_and_draw_in_the_selected_language() { - let labels = ["Contents:", "Color:", "Custom Pattern:", "Mode:", "Opacity:", "{size} (was {old})"]; - for language in Lang::all().filter(|language| language.complete_menus()) { - let contents = crate::fill_ui::CONTENTS.iter().map(|(_, label)| *label); - let contours = photocraft_engine::layer_style::CONTOURS.iter().map(|(name, _)| *name); - for text in labels.into_iter().chain(contents).chain(contours) { - assert!(has(language, text), "{}: untranslated {text}", language.code()); - } - } +fn fill_dialog_draws_in_the_selected_language() { with_language(Lang::EN, || { let mut h = harness(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "pt-br"})).expect("language"); @@ -163,21 +131,15 @@ fn fill_dialog_and_contour_choices_have_translations_and_draw_in_the_selected_la crate::menus::invoke(h.state_mut(), &ctx, crate::fill_ui::COMMAND, json!({})).expect("fill dialog"); h.run_steps(4); let text = drawn_text(&h); - for want in ["Conteúdo:", "Cor de primeiro plano", "Modo:", "Opacidade:"] { + for source in ["Contents:", "Foreground Color", "Mode:", "Opacity:"] { + let want = tr(lang("pt-br"), source); assert!(text.iter().any(|text| text == want), "missing {want}"); } - assert!(!text.iter().any(|text| text == "Contents:" || text == "Foreground Color")); }); } #[test] -fn file_info_camera_data_has_translations_and_draws_read_only() { - for language in Lang::all().filter(|language| language.complete_menus()) { - assert!(has(language, "Camera Data"), "{}: untranslated Camera Data", language.code()); - for (_, label) in photocraft_engine::file_cmds::CAMERA_FIELDS { - assert!(language.catalog().contextual("cameraData", label).is_some(), "{}: untranslated cameraData {label}", language.code()); - } - } +fn file_info_camera_data_draws_in_the_selected_language_read_only() { with_language(Lang::EN, || { let mut h = harness(); let exif = photocraft_algo::exif::build(&photocraft_algo::exif::CameraInfo { @@ -199,7 +161,9 @@ fn file_info_camera_data_has_translations_and_draws_read_only() { assert_eq!(h.state_mut().ui.dialog_mut(dialog).expect("open").fields["__camera"]["exposure"], "1/250 s"); h.run_steps(4); let text = drawn_text(&h); - for want in ["Kameradaten", "Hersteller", "SONY", "Belichtungszeit", "1/250 s", "Blende", "f/8"] { + let de = lang("de"); + let labels = [tr(de, "Camera Data"), tr_ctx(de, "cameraData", "Make"), tr_ctx(de, "cameraData", "Exposure"), tr_ctx(de, "cameraData", "F-Stop")]; + for want in labels.into_iter().chain(["SONY", "1/250 s", "f/8"]) { assert!(text.iter().any(|text| text == want), "missing {want}: {text:?}"); } // Read-only: confirming submits only the editable fields. @@ -360,8 +324,9 @@ fn preferences_preview_does_not_commit_until_applied_and_survives_reload() { dialog.fields.get_mut("values").expect("values")["interface"]["language"] = json!("fr"); h.run_steps(3); let text = drawn_text(&h); - assert!(text.iter().any(|text| text == "Apparence"), "the draft language must translate the visible dialog"); - assert!(text.iter().any(|text| text == "Annuler"), "the dialog buttons must preview the language too"); + let fr = lang("fr"); + assert!(text.iter().any(|text| text == tr(fr, "Appearance")), "the draft language must translate the visible dialog"); + assert!(text.iter().any(|text| text == tr(fr, "Cancel")), "the dialog buttons must preview the language too"); assert!(text.iter().any(|text| text == "File"), "the main window keeps its committed language"); assert_eq!(h.state().session.prefs().interface.language, "auto"); assert_eq!(current(), Lang::EN, "preview must not leak to the main window"); @@ -388,14 +353,15 @@ fn keyboard_shortcuts_list_commands_in_the_selected_language() { h.step(); let ctx = h.ctx.clone(); let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).expect("dialog")["dialog"].as_u64().expect("id"); + let pt = lang("pt-br"); + let label = tr_id(pt, "file.saveAs", "Save As…"); // Search matches the translated label and the English one. - for filter in ["salvar como", "save as"] { + for filter in [label.trim_end_matches('…').to_lowercase(), "save as".to_string()] { h.state_mut().ui.dialog_mut(id).expect("shortcuts").fields.insert("filter".into(), json!(filter)); h.run_steps(3); let text = drawn_text(&h); - assert!(text.iter().any(|text| text == "Arquivo"), "{filter}: the menu heading must be translated"); - assert!(text.iter().any(|text| text.trim() == "Salvar como…"), "{filter}: the command must be translated: {text:?}"); - assert!(!text.iter().any(|text| text.trim() == "Save As…"), "{filter}: no English command labels"); + assert!(text.iter().any(|text| text == tr(pt, "File")), "{filter}: the menu heading must be translated"); + assert!(text.iter().any(|text| text.trim() == label), "{filter}: the command must be translated: {text:?}"); } }); } @@ -432,13 +398,18 @@ fn czech_status_notices_and_navigator_draw_translated_text() { None, ); let info = crate::gpu_canvas::GpuInfo { driver: "Mesa 25.0".into(), ..Default::default() }; - assert!(info.lines().iter().any(|line| line == "Ovladač: Mesa 25.0")); + assert!(info.lines().iter().any(|line| *line == fmt(tr(lang("cs"), "Driver: {driver}"), &[("driver", "Mesa 25.0")]))); }); h.run_steps(4); let text = drawn_text(&h); + let cs = lang("cs"); // The Navigator's own text is its tab title now: its zoom row has no label (#4840). - for want in ["Navigátor", "Soubor English file.psd se nepodařilo otevřít: disk full", "Předvolby nelze uložit"] { - assert!(text.iter().any(|text| text == want), "missing {want}: {text:?}"); + for want in [ + tr(cs, "Navigator").to_string(), + fmt(tr(cs, "Couldn't open {name}: {error}"), &[("name", "English file.psd"), ("error", "disk full")]), + tr(cs, "Preferences can't be saved").to_string(), + ] { + assert!(text.contains(&want), "missing {want}: {text:?}"); } // Czech text must be drawable by the bundled fonts, including the new notices. let chars: std::collections::BTreeSet<_> = text.iter().flat_map(|s| s.chars()).filter(|c| !c.is_whitespace()).collect(); @@ -469,7 +440,12 @@ fn czech_shortcut_conflicts_keep_warning_state_and_shortcut_codes() { h.run_steps(4); let fields = &h.state().ui.dialogs.iter().find(|d| d.id == id).expect("dialog").fields; assert_eq!(fields["messageWarning"], json!(true)); - assert!(fields["message"].as_str().expect("message").contains("již používá")); + let message = fields["message"].as_str().expect("message"); + let template = tr(lang("cs"), "{shortcut} is already in use by {commands} and will be removed from it when you click OK."); + // The message is the conflict template, filled in: its fixed text appears around the shortcut and commands. + for piece in template.split('{').map(|piece| piece.split_once('}').map_or(piece, |(_, rest)| rest)).filter(|piece| !piece.trim().is_empty()) { + assert!(message.contains(piece), "{message:?} lacks {piece:?}"); + } assert_eq!(fields["overrides"]["file.new"], json!("Cmd+O")); }); } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 0ff8a30432..22b9ff60c4 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -43,7 +43,7 @@ pub struct LangInfo { /// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per /// index. pub plural: fn(u64) -> usize, - /// Must the catalog cover every menu string? (checked by the tests) + /// Does the catalog cover every menu string? (`cargo xtask i18n-coverage` lists what it lacks) pub complete_menus: bool, catalog: OnceLock, } @@ -479,10 +479,6 @@ mod tests { assert_eq!(HI().name(), "हिन्दी"); assert_eq!(Lang::from_pref("hi"), HI()); assert_eq!(Lang::from_pref("HI"), HI()); - assert_eq!(tr(HI(), "Layer"), "लेयर"); - assert_eq!(tr(HI(), "File"), "फ़ाइल"); - assert_eq!(trn(HI(), 1, "{n} layer", "{n} layers"), "1 लेयर"); - assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयर्स"); } #[test] @@ -499,7 +495,6 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}"); } assert_eq!(VI().name(), "Tiếng Việt"); - assert!(VI().complete_menus(), "Vietnamese translates every source key"); assert_eq!(Lang::from_pref("vi"), VI()); assert_eq!(Lang::from_pref("VI"), VI()); } @@ -578,87 +573,21 @@ mod tests { assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN); } - /// The brush size/hardness and preset-stepping shortcut rows (`tools.*` commands without a menu item) must - /// be translated in every catalog, or they show English in the Keyboard Shortcuts dialog and cannot be found - /// by searching. #[test] - fn brush_size_and_hardness_shortcuts_are_translated_in_every_language() { - let keys = [ - "Decrease Brush Size", - "Increase Brush Size", - "Decrease Brush Hardness", - "Increase Brush Hardness", - "Previous Brush", - "Next Brush", - "First Brush", - "Last Brush", - ]; - for lang in Lang::all().filter(|lang| *lang != Lang::EN) { - for key in keys { - assert!(has(lang, key), "{} has no translation for {key:?}", lang.code()); - } - } + fn simplified_chinese_resolves_from_preferences() { let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - assert_eq!(tr(zh, "Decrease Brush Size"), "减小画笔大小"); - assert_eq!(tr(zh, "Increase Brush Size"), "增大画笔大小"); - } - - #[test] - fn simplified_chinese_covers_dynamic_shortcuts_and_layer_counts() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - assert!(zh.complete_menus(), "Simplified Chinese must participate in the coverage gates"); assert_eq!(Lang::from_pref("ZH-Hans"), zh); - assert_eq!(tr(zh, "Pixel Layer"), "像素图层"); - assert_eq!(tr(zh, "System Info"), "系统信息"); - for key in ["⌥", "Alt"] { - assert_eq!(fmt(tr(zh, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), format!("添加蒙版 (基于选区;{key} 反相)")); - } - for n in [0, 1, 3] { - assert_eq!(trn(zh, n, "{n} layer", "{n} layers"), format!("{n} 个图层")); - } assert_eq!(tr(zh, "no such label"), "no such label"); } - /// Keep the Korean tool/menu vocabulary aligned with the Photoshop equivalents. - /// Sources and the product-specific vocabulary policy are recorded in `ko.tsv`. - #[test] - fn korean_uses_photoshop_terminology() { - let ko = Lang::from_code("ko").expect("ko registered"); - for (source, expected) in [ - ("Shape", "모양"), - ("Stroke", "획"), - ("Smudge Tool", "손가락 도구"), - ("Eyedropper Tool", "스포이드 도구"), - ("Rectangular Marquee Tool", "사각형 선택 윤곽 도구"), - ("Elliptical Marquee Tool", "원형 선택 윤곽 도구"), - ("Zoom Tool", "돋보기 도구"), - ("Horizontal Type Tool", "수평 문자 도구"), - ("Puppet Warp", "퍼펫 뒤틀기"), - ("Liquify…", "픽셀 유동화…"), - ("Gaussian Blur…", "가우시안 흐림 효과…"), - ("Gaussian Blur", "가우시안 흐림 효과"), - ("Adaptive Wide Angle…", "응용 광각…"), - ("Render", "렌더"), - ("Sharpen", "선명 효과"), - ("Vibrance", "활기"), - ("Layer Comps", "레이어 구성 요소"), - ("Vivid Light", "선명한 라이트"), - ("Hard Mix", "하드 혼합"), - ] { - assert_eq!(tr(ko, source), expected, "{source}"); - } - assert_eq!(tr_id(ko, "filter.sharpen.sharpen", "Sharpen"), "선명하게"); - } - #[test] fn spanish_resolves_and_pluralises() { let es = Lang::from_code("es").expect("es registered"); for tag in ["es", "es_ES.UTF-8", "es-MX", "es-419"] { assert_eq!(lang_from_tag(tag), Some(es), "{tag}"); } - assert_eq!(tr(es, "Layer"), "Capa"); - assert_eq!(trn(es, 1, "{n} item", "{n} items"), "1 elemento"); - assert_eq!(trn(es, 3, "{n} item", "{n} items"), "3 elementos"); + let forms: Vec = [0, 1, 3].into_iter().map(es.0.plural).collect(); + assert_eq!(forms, [1, 0, 1]); } #[test] @@ -667,14 +596,9 @@ mod tests { for tag in ["hu", "hu_HU.UTF-8", "hu-HU"] { assert_eq!(lang_from_tag(tag), Some(hu), "{tag}"); } - assert!(hu.complete_menus()); assert_eq!(hu.name(), "Magyar"); - assert_eq!(tr(hu, "Layer"), "Réteg"); - for (n, expected) in [(0, "0 réteg"), (1, "1 réteg"), (3, "3 réteg"), (21, "21 réteg")] { - assert_eq!(trn(hu, n, "{n} layer", "{n} layers"), expected); - } - assert_eq!(tr_ctx(hu, "layerLabel", "Red"), "Piros"); - assert_eq!(tr(hu, "Red"), "Vörös"); + let forms: Vec = [0, 1, 3, 21].into_iter().map(hu.0.plural).collect(); + assert_eq!(forms, [0, 0, 0, 0]); } #[test] @@ -684,241 +608,63 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(pt), "{tag}"); } assert_eq!(candidates("pt_PT"), ["pt-pt", "pt-br", "pt"]); - assert_eq!(tr(pt, "Layer"), "Camada"); - assert_eq!(trn(pt, 0, "{n} item", "{n} items"), "0 item"); - assert_eq!(trn(pt, 1, "{n} item", "{n} items"), "1 item"); - assert_eq!(trn(pt, 2, "{n} item", "{n} items"), "2 itens"); + let forms: Vec = [0, 1, 2].into_iter().map(pt.0.plural).collect(); + assert_eq!(forms, [0, 0, 1]); } #[test] fn lookups_fall_back_to_english() { assert_eq!(tr(JA(), "no such label"), "no such label"); assert_eq!(tr(Lang::EN, "Layer"), "Layer"); - assert_eq!(tr(JA(), "Layer"), "レイヤー"); - assert_eq!(tr(ZH(), "Layer"), "圖層"); assert_eq!(tr(ZH(), "no such label"), "no such label"); - assert_eq!(tr_id(ZH(), "no.such.id", "Layer"), "圖層"); - assert_eq!(tr_id(JA(), "no.such.id", "Layer"), "レイヤー"); - assert_eq!(tr_ctx(JA(), "no such context", "Layer"), "レイヤー"); + assert_eq!(tr_id(ZH(), "no.such.id", "Layer"), tr(ZH(), "Layer")); + assert_eq!(tr_id(JA(), "no.such.id", "Layer"), tr(JA(), "Layer")); + assert_eq!(tr_ctx(JA(), "no such context", "Layer"), tr(JA(), "Layer")); + assert_eq!(trn(JA(), 3, "{n} no such message", "{n} no such messages"), "3 no such messages"); } #[test] - fn russian_plural_rules() { - let ru = || Lang::from_code("ru").expect("ru registered"); - assert_eq!(trn(ru(), 1, "{n} item", "{n} items"), "1 элемент"); - assert_eq!(trn(ru(), 2, "{n} item", "{n} items"), "2 элемента"); - assert_eq!(trn(ru(), 5, "{n} item", "{n} items"), "5 элементов"); - assert_eq!(trn(ru(), 11, "{n} item", "{n} items"), "11 элементов"); - assert_eq!(trn(ru(), 21, "{n} item", "{n} items"), "21 элемент"); - assert_eq!(trn(ru(), 22, "{n} item", "{n} items"), "22 элемента"); - assert_eq!(trn(ru(), 101, "{n} item", "{n} items"), "101 элемент"); - assert_eq!(trn(ru(), 111, "{n} item", "{n} items"), "111 элементов"); - } - - #[test] - fn russian_status_messages_agree_with_counts() { + fn russian_plural_rule() { let ru = Lang::from_code("ru").expect("ru registered"); - let cases = [ - ( - "Exported {n} swatch to {file}", - "Exported {n} swatches to {file}", - ["Экспортирован {n} образец в {file}", "Экспортированы {n} образца в {file}", "Экспортировано {n} образцов в {file}"], - ), - ( - "Imported {n} brush from {group}", - "Imported {n} brushes from {group}", - ["Импортирована {n} кисть из {group}", "Импортированы {n} кисти из {group}", "Импортировано {n} кистей из {group}"], - ), - ( - "Imported {n} swatch from {file}", - "Imported {n} swatches from {file}", - ["Импортирован {n} образец из {file}", "Импортированы {n} образца из {file}", "Импортировано {n} образцов из {file}"], - ), - ("Played {n} step", "Played {n} steps", ["Воспроизведён {n} шаг", "Воспроизведены {n} шага", "Воспроизведено {n} шагов"]), - ( - "Updated {n} linked smart object changed on disk", - "Updated {n} linked smart objects changed on disk", - [ - "Обновлён {n} связанный смарт-объект, изменённый на диске", - "Обновлены {n} связанных смарт-объекта, изменённых на диске", - "Обновлено {n} связанных смарт-объектов, изменённых на диске", - ], - ), - ( - "{n} layer recorded by this comp was deleted. Update the comp, or clear the warning.", - "{n} layers recorded by this comp were deleted. Update the comp, or clear the warning.", - [ - "Удалён {n} слой, записанный в этом варианте. Обновите вариант или сбросьте предупреждение.", - "Удалены {n} слоя, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.", - "Удалено {n} слоёв, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.", - ], - ), - ("{n} sample point removed", "{n} sample points removed", ["Удалена {n} точка замера", "Удалены {n} точки замера", "Удалено {n} точек замера"]), - ( - "Loaded {n} action from {file}", - "Loaded {n} actions from {file}", - ["Загружена {n} операция из {file}", "Загружены {n} операции из {file}", "Загружено {n} операций из {file}"], - ), - ( - "Saved {n} action to {file}", - "Saved {n} actions to {file}", - ["Сохранена {n} операция в {file}", "Сохранены {n} операции в {file}", "Сохранено {n} операций в {file}"], - ), - ( - "{n} step with no Photoshop equivalent left out", - "{n} steps with no Photoshop equivalent left out", - [ - "Пропущен {n} шаг без эквивалента в Photoshop", - "Пропущены {n} шага без эквивалента в Photoshop", - "Пропущено {n} шагов без эквивалента в Photoshop", - ], - ), - ]; - // Include zero, teens, and compound counts as well as each basic form. - for (n, form) in [(0, 2), (1, 0), (2, 1), (3, 1), (4, 1), (5, 2), (11, 2), (12, 2), (14, 2), (21, 0), (22, 1), (25, 2), (101, 0), (112, 2)] { - for (one, other, forms) in cases { - let expected = forms[form].replace("{n}", &n.to_string()); - assert_eq!(trn(ru, n, one, other), expected, "{one}, n={n}"); - } - } - assert_eq!( - fmt(&trn(ru, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"), &[("group", "Кисти")]), - "Импортированы 2 кисти из Кисти" - ); - assert_eq!( - fmt(&trn(ru, 22, "Loaded {n} action from {file}", "Loaded {n} actions from {file}"), &[("file", "actions.atn")]), - "Загружены 22 операции из actions.atn" - ); - } - - #[test] - fn russian_font_weights_and_reviewed_labels() { - let ru = Lang::from_code("ru").expect("ru registered"); - assert_eq!(tr_ctx(ru, "fontWeight", "ExtraLight"), "Сверхтонкий"); - assert_eq!(tr_ctx(ru, "fontWeight", "Light"), "Лёгкий"); - assert_eq!(tr_ctx(ru, "fontWeight", "ExtraBold"), "Сверхжирный"); - assert_eq!(tr_ctx(ru, "fontWeight", "Black"), "Сверхжирный (Black)"); - assert_eq!(tr(ru, "Delete and Fill Selection"), "Удалить и залить выделение"); - assert_eq!(tr(ru, "Discard the embedded profile (don't color manage)"), "Не использовать встроенный профиль (без управления цветом)"); - assert_eq!(tr(ru, "No object found there — drag a rectangle around it"), "Объект не найден — обведите его прямоугольником"); + let forms: Vec = [0, 1, 2, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112].into_iter().map(ru.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2]); } #[test] fn ukrainian_resolves_locales_and_preferences() { let uk = Lang::from_code("uk").expect("uk registered"); assert_eq!(uk.name(), "Українська"); - assert!(uk.complete_menus()); for tag in ["uk", "UK", "uk-UA", "uk_UA", "uk_UA.UTF-8", "uk-UA@euro", "uk-Cyrl-UA"] { assert_eq!(lang_from_tag(tag), Some(uk), "{tag}"); assert_eq!(Lang::from_pref(tag), uk, "{tag}"); } - assert_eq!(tr(uk, "File"), "Файл"); - assert_eq!(tr(uk, "Layer"), "Шар"); - assert_eq!(tr(uk, "New document…"), "Новий документ…"); - assert_eq!(tr(uk, "No properties"), "Немає властивостей"); - assert_eq!(tr_id(uk, "select.all", "All"), "Виділити все"); - assert_eq!(tr_ctx(uk, "cameraRaw", "Light"), "Світло"); - assert_eq!(tr_ctx(uk, "fontWeight", "Light"), "Легкий"); - assert_eq!(fmt(tr(uk, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Фільтр Camera Raw (Background)"); - for key in ["Alt", "⌥"] { - assert_eq!( - fmt(tr(uk, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), - format!("Додати маску (із виділення; {key} інвертує)") - ); - } assert_eq!(tr(uk, "unknown translation"), "unknown translation"); } #[test] - fn ukrainian_plural_messages_handle_teens_and_compound_counts() { + fn ukrainian_plural_rule_handles_teens_and_compound_counts() { let uk = Lang::from_code("uk").expect("uk registered"); - for (n, item, layer) in [ - (0, "елементів", "шарів"), - (1, "елемент", "шар"), - (2, "елементи", "шари"), - (4, "елементи", "шари"), - (5, "елементів", "шарів"), - (11, "елементів", "шарів"), - (12, "елементів", "шарів"), - (14, "елементів", "шарів"), - (19, "елементів", "шарів"), - (21, "елемент", "шар"), - (22, "елементи", "шари"), - (24, "елементи", "шари"), - (25, "елементів", "шарів"), - (100, "елементів", "шарів"), - (101, "елемент", "шар"), - (111, "елементів", "шарів"), - (112, "елементів", "шарів"), - (114, "елементів", "шарів"), - (121, "елемент", "шар"), - (122, "елементи", "шари"), - (u64::MAX, "елементів", "шарів"), - ] { - assert_eq!(trn(uk, n, "{n} item", "{n} items"), format!("{n} {item}")); - assert_eq!(trn(uk, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); - assert_eq!(trn(uk, n, "Group · {n} layer", "Group · {n} layers"), format!("Група · {n} {layer}")); - } + let counts = [0, 1, 2, 4, 5, 11, 12, 14, 19, 21, 22, 24, 25, 100, 101, 111, 112, 114, 121, 122, u64::MAX]; + let forms: Vec = counts.into_iter().map(uk.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 0, 1, 1, 2, 2, 0, 2, 2, 2, 0, 1, 2]); } #[test] fn serbian_resolves_locales_and_preferences() { let sr = Lang::from_code("sr").expect("sr registered"); assert_eq!(sr.name(), "Srpski"); - assert!(sr.complete_menus()); for tag in ["sr", "SR", "sr-RS", "sr_RS", "sr_RS.UTF-8", "sr_RS@latin", "sr-Latn", "sr-Latn-RS", "sr-Cyrl-RS", "sr-ME"] { assert_eq!(lang_from_tag(tag), Some(sr), "{tag}"); assert_eq!(Lang::from_pref(tag), sr, "{tag}"); } - assert_eq!(tr(sr, "File"), "Datoteka"); - assert_eq!(tr(sr, "Layer"), "Sloj"); - assert_eq!(tr(sr, "New document…"), "Novi dokument…"); - assert_eq!(tr_id(sr, "select.all", "All"), "Izaberi sve", "an id override wins over the plain label"); - assert_eq!(tr(sr, "All"), "Sve"); - assert_eq!(tr_ctx(sr, "cameraRaw", "Light"), "Svetlo"); - assert_eq!(tr_ctx(sr, "fontWeight", "Light"), "Lagano"); - assert_eq!(fmt(tr(sr, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Camera Raw filter (Background)"); - for key in ["Alt", "⌥"] { - assert_eq!( - fmt(tr(sr, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), - format!("Dodaj masku (iz selekcije; {key} invertuje)") - ); - } } #[test] - fn serbian_plural_messages_handle_teens_and_compound_counts() { + fn serbian_plural_rule_handles_teens_and_compound_counts() { let sr = Lang::from_code("sr").expect("sr registered"); - for (n, item, layer) in [ - (0, "stavki", "slojeva"), - (1, "stavka", "sloj"), - (2, "stavke", "sloja"), - (4, "stavke", "sloja"), - (5, "stavki", "slojeva"), - (11, "stavki", "slojeva"), - (12, "stavki", "slojeva"), - (14, "stavki", "slojeva"), - (21, "stavka", "sloj"), - (22, "stavke", "sloja"), - (25, "stavki", "slojeva"), - (101, "stavka", "sloj"), - (111, "stavki", "slojeva"), - (112, "stavki", "slojeva"), - (122, "stavke", "sloja"), - ] { - assert_eq!(trn(sr, n, "{n} item", "{n} items"), format!("{n} {item}")); - assert_eq!(trn(sr, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); - assert_eq!(trn(sr, n, "Group · {n} layer", "Group · {n} layers"), format!("Grupa · {n} {layer}")); - } - for (n, expected) in [ - (1, "Uklonjena je 1 tačka uzorkovanja"), - (3, "Uklonjene su 3 tačke uzorkovanja"), - (5, "Uklonjeno je 5 tačaka uzorkovanja"), - (13, "Uklonjeno je 13 tačaka uzorkovanja"), - (21, "Uklonjena je 21 tačka uzorkovanja"), - ] { - assert_eq!(trn(sr, n, "{n} sample point removed", "{n} sample points removed"), expected); - } + let counts = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112, 122]; + let forms: Vec = counts.into_iter().map(sr.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2, 1]); } #[test] @@ -945,14 +691,14 @@ mod tests { assert_eq!(trn(Lang::EN, 1, "{n} item", "{n} items"), "1 item"); assert_eq!(trn(Lang::EN, 0, "{n} item", "{n} items"), "0 items"); assert_eq!(trn(Lang::EN, 7, "{n} item", "{n} items"), "7 items"); - assert_eq!(trn(JA(), 1, "{n} item", "{n} items"), "1 件"); - assert_eq!(trn(JA(), 7, "{n} item", "{n} items"), "7 件"); - assert_eq!(trn(ZH(), 1, "{n} item", "{n} items"), "1 個項目"); - assert_eq!(trn(ZH(), 7, "{n} item", "{n} items"), "7 個項目"); - assert_eq!(trn(CS(), 1, "{n} item", "{n} items"), "1 položka"); - assert_eq!(trn(CS(), 3, "{n} item", "{n} items"), "3 položky"); - assert_eq!(trn(CS(), 5, "{n} item", "{n} items"), "5 položek"); - assert_eq!(trn(CS(), 0, "{n} item", "{n} items"), "0 položek"); + let cs = fixture_lang(plural_cs, "@plural\t{n} item|{n} items\t{n} one|{n} few|{n} other\n"); + for (n, expected) in [(0, "0 other"), (1, "1 one"), (3, "3 few"), (5, "5 other")] { + assert_eq!(trn(cs, n, "{n} item", "{n} items"), expected); + } + let ja = fixture_lang(plural_none, "@plural\t{n} item|{n} items\t{n} only\n"); + assert_eq!(trn(ja, 1, "{n} item", "{n} items"), "1 only"); + assert_eq!(trn(ja, 7, "{n} item", "{n} items"), "7 only"); + assert_eq!(trn(ja, 7, "{n} file", "{n} files"), "7 files", "a missing row falls back to English"); assert_eq!(fmt("{b} before {a}", &[("a", "x"), ("b", "y"), ("c", "z")]), "y before x"); assert_eq!(fmt("{missing}", &[]), "{missing}"); assert_eq!(placeholders("a {x} b {y} {"), ["x", "y"]); @@ -964,14 +710,27 @@ mod tests { for tag in ["fr", "fr_FR.UTF-8", "fr-CA", "fr_BE", "fr-CH"] { assert_eq!(lang_from_tag(tag), Some(fr), "{tag}"); } - assert_eq!(tr(fr, "Layer"), "Calque"); - assert_eq!(tr_id(fr, "select.all", "All"), "Tout sélectionner", "an id override wins over the plain label"); - assert_eq!(tr(fr, "All"), "Tout"); - let forms: Vec = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(plural_fr).collect(); + let forms: Vec = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(fr.0.plural).collect(); assert_eq!(forms, [0, 0, 1, 1, 1, 1]); - assert_eq!(trn(fr, 0, "{n} item", "{n} items"), "0 élément"); - assert_eq!(trn(fr, 1, "{n} item", "{n} items"), "1 élément"); - assert_eq!(trn(fr, 3, "{n} item", "{n} items"), "3 éléments"); + } + + /// A language whose catalog is `text`, for testing lookups without depending on a bundled + /// catalog's wording. + fn fixture_lang(plural: fn(u64) -> usize, text: &str) -> Lang { + let catalog = OnceLock::new(); + let _ = catalog.set(Catalog::parse(text)); + Lang(Box::leak(Box::new(LangInfo { code: "xx", name: "x", source: b"", plural, complete_menus: false, catalog }))) + } + + #[test] + fn id_and_context_rows_win_over_the_plain_label() { + let lang = fixture_lang(plural_one_other, "\tAll\tplain all\n@id\tselect.all\tid all\nnavigator\tZoom\tcontext zoom\n\tZoom\tplain zoom\n"); + assert_eq!(tr_id(lang, "select.all", "All"), "id all"); + assert_eq!(tr_id(lang, "edit.other", "All"), "plain all"); + assert_eq!(tr(lang, "All"), "plain all"); + assert_eq!(tr_ctx(lang, "navigator", "Zoom"), "context zoom"); + assert_eq!(tr_ctx(lang, "other", "Zoom"), "plain zoom"); + assert_eq!(tr_ctx(lang, "navigator", "Unlisted"), "Unlisted"); } #[test] @@ -980,61 +739,10 @@ mod tests { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 1, 2, 2, 1]); } - /// Counted status messages read in Simplified Chinese instead of falling back to English (they were - /// missing from the catalog). - #[test] - fn simplified_chinese_counted_messages_are_translated() { - let lang = Lang::from_code("zh-hans").expect("zh-hans registered"); - assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "已播放 3 个步骤"); - let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); - assert_eq!(fmt(&brushes, &[("group", "水彩")]), "已从 水彩 导入 2 个画笔"); - assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 个切片"); - } - - /// Counted status messages read in Korean instead of falling back to English (they were - /// missing from the catalog). - #[test] - fn korean_counted_messages_are_translated() { - let lang = Lang::from_code("ko").expect("ko registered"); - assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "단계 3개를 재생했습니다"); - let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); - assert_eq!(fmt(&brushes, &[("group", "수채화")]), "수채화에서 브러시 2개를 가져왔습니다"); - assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "분할 영역 5개"); - } - - /// Counted status messages read in Indonesian instead of falling back to English (they were - /// missing from the catalog). - #[test] - fn indonesian_counted_messages_are_translated() { - let lang = Lang::from_code("id").expect("id registered"); - assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "3 langkah diputar"); - let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); - assert_eq!(fmt(&brushes, &[("group", "Cat Air")]), "2 kuas diimpor dari Cat Air"); - assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 slice"); - } - - /// Counted status messages read in Polish with all three forms instead of falling back to - /// English (they were missing from the catalog). - #[test] - fn polish_counted_messages_use_all_three_forms() { - let pl = Lang::from_code("pl").expect("pl registered"); - let steps = |n| trn(pl, n, "Played {n} step", "Played {n} steps"); - assert_eq!( - [steps(1), steps(3), steps(5), steps(12), steps(22)], - ["Odtworzono 1 krok", "Odtworzono 3 kroki", "Odtworzono 5 kroków", "Odtworzono 12 kroków", "Odtworzono 22 kroki"] - ); - let brushes = trn(pl, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); - assert_eq!(fmt(&brushes, &[("group", "Akwarele")]), "Zaimportowano 2 pędzle z Akwarele"); - assert_eq!(trn(pl, 5, "{n} slice", "{n} slices"), "5 plasterków"); - } - #[test] fn czech_plural_rule() { let forms: Vec = [0, 1, 2, 3, 4, 5, 11, 12, 21, 22, 100, u64::MAX].into_iter().map(plural_cs).collect(); assert_eq!(forms, [2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2]); - assert_eq!(tr(CS(), "Layer"), "Vrstva"); - assert_eq!(tr_id(CS(), "select.all", "All"), "Vybrat vše", "an id override wins over the plain label"); - assert_eq!(tr(CS(), "All"), "Vše"); } /// The packed catalogs in the binary unpack to exactly the `.tsv` files. @@ -1117,428 +825,6 @@ mod tests { } } } - - /// Languages that claim complete menus have an entry for every label and path segment. - #[test] - fn complete_languages_translate_every_menu_string() { - let mut strings = std::collections::BTreeSet::new(); - for &(path, label, _, _) in crate::menu_catalog::CATALOG { - strings.extend(path.iter().copied()); - strings.insert(label); - } - for &(_, label, path, _) in crate::menus::UI_COMMANDS { - strings.extend(path.iter().copied()); - strings.insert(label); - } - for c in photocraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty()) { - strings.extend(c.menu.iter().copied()); - strings.insert(c.label); - } - strings.remove("---"); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let cat = l.catalog(); - let missing: Vec<_> = strings.iter().filter(|s| cat.plain(s).is_none()).collect(); - assert!(missing.is_empty(), "{}: untranslated menu strings: {missing:#?}", l.code); - } - } - - /// `text` with each `#[cfg(test)] mod … { … }` block removed, by brace counting (the test - /// modules in this crate are well formed). - fn without_test_modules(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut rest = text; - while let Some(at) = rest.find("#[cfg(test)]\nmod ") { - out.push_str(&rest[..at]); - let after = &rest[at..]; - let Some(open) = after.find('{') else { return out }; - let mut depth = 0usize; - let mut end = after.len(); - for (i, c) in after[open..].char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - end = open + i + 1; - break; - } - } - _ => {} - } - } - rest = &after[end..]; - } - out.push_str(rest); - out - } - - #[test] - fn test_modules_are_cut_out_one_by_one() { - let text = "a\n#[cfg(test)]\nmod t { fn x() { tl!(\"hidden\"); } }\nb tl!(\"seen\")\n#[cfg(test)]\nmod u {}\nc"; - let code = without_test_modules(text); - assert!(code.contains("tl!(\"seen\")"), "code after the first test module is scanned: {code}"); - assert!(!code.contains("hidden"), "{code}"); - assert!(code.starts_with("a\n") && code.ends_with("\nc"), "{code}"); - } - - #[test] - fn czech_status_templates_preserve_details_and_plural_counts() { - let cs = CS(); - let template = "Step {step} ({id}) failed: {error}"; - let args = [("step", "2"), ("id", "file.export"), ("error", "disk full: /tmp/image.psd")]; - assert_eq!(fmt(tr(cs, template), &args), "Krok 2 (file.export) selhal: disk full: /tmp/image.psd"); - assert_eq!(fmt(tr(Lang::EN, template), &args), "Step 2 (file.export) failed: disk full: /tmp/image.psd"); - // Languages without these dynamic templates keep the English source. - assert_eq!(tr(JA(), template), template); - for (n, expected) in [(0, "Odstraněno 0 bodů měření"), (1, "Odstraněn 1 bod měření"), (2, "Odstraněny 2 body měření"), (5, "Odstraněno 5 bodů měření")] - { - assert_eq!(trn(cs, n, "{n} sample point removed", "{n} sample points removed"), expected); - } - let one = "Imported {n} swatch from {file}"; - let other = "Imported {n} swatches from {file}"; - assert_eq!(fmt(&trn(cs, 3, one, other), &[("file", "English name.aco")]), "Importovány 3 vzorky ze souboru English name.aco"); - assert_eq!(tr(cs, "Preferences can't be saved"), "Předvolby nelze uložit"); - } - - #[test] - fn czech_dialog_and_hardware_labels_preserve_names() { - let cs = CS(); - for label in [ - "New Crop Preset…", - "Delete Crop Preset…", - "Color Picker (Background Color)", - "Color Picker (Foreground Color)", - "Pro (Medium Gray)", - "Grid view", - "List view", - "Scroll menu up", - "Scroll menu down", - "Commit transform", - "Cancel transform", - ] { - assert_ne!(tr(cs, label), label, "{label}"); - } - for kind in [ - crate::rasterize_prompt::Kind::Type, - crate::rasterize_prompt::Kind::Shape, - crate::rasterize_prompt::Kind::SmartObject, - crate::rasterize_prompt::Kind::Fill, - ] { - assert_ne!(tr(cs, kind.message()), kind.message()); - } - assert_eq!(fmt(tr(cs, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Ovladač: Mesa 25.0"); - assert_eq!( - fmt(tr(cs, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]), - "Profil monitoru DELL U2720Q: sRGB IEC61966-2.1" - ); - } - - #[test] - fn czech_composed_labels_use_context_and_preserve_document_names() { - let cs = CS(); - assert_eq!(tr(cs, "Zoom"), "Lupa"); - assert_eq!(tr_ctx(cs, "navigator", "Zoom"), "Zvětšení"); - assert_eq!(tr_ctx(Lang::EN, "navigator", "Zoom"), "Zoom"); - let template = "Data Set: {name} ({index}/{total})"; - assert_eq!(fmt(tr(cs, template), &[("name", "Data Set 1"), ("index", "1"), ("total", "2")]), "Datová sada: Data Set 1 (1/2)"); - for (n, expected) in - [(1, "Importován 1 přechod ze souboru Sunset"), (3, "Importovány 3 přechody ze souboru Sunset"), (5, "Importováno 5 přechodů ze souboru Sunset")] - { - assert_eq!(fmt(&trn(cs, n, "Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), &[("file", "Sunset")]), expected); - } - } - - /// Russian dynamic labels added in the fourth pass (filter parameters, Camera Raw panels, - /// hardware status, rasterize prompts, History/paths menus) render in Russian, not English. - #[test] - fn russian_dynamic_status_hardware_and_dialog_labels_are_translated() { - let ru = Lang::from_code("ru").expect("ru registered"); - for kind in [ - crate::rasterize_prompt::Kind::Type, - crate::rasterize_prompt::Kind::Shape, - crate::rasterize_prompt::Kind::SmartObject, - crate::rasterize_prompt::Kind::Fill, - ] { - assert_ne!(tr(ru, kind.message()), kind.message(), "{:?}", kind); - } - assert_eq!(fmt(tr(ru, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Драйвер: Mesa 25.0"); - assert_eq!( - fmt(tr(ru, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]), - "Профиль монитора для DELL U2720Q: sRGB IEC61966-2.1" - ); - assert_eq!(fmt(tr(ru, "Platform: {os} {arch}"), &[("os", "Linux"), ("arch", "x86_64")]), "Платформа: Linux x86_64"); - assert_eq!(tr(ru, "Graphics adapter"), "Графический адаптер"); - assert_eq!(tr(ru, "Image compositor: {processor}"), "Компоновщик изображения: {processor}"); - // Filter / adjustment parameter names. - for label in [ - "Amount", - "Radius", - "Threshold", - "Angle", - "High Pass", - "Smart Blur", - "Reduce Noise", - "Lens Correction", - "Colored Pencil", - "Angled Strokes", - "Stained Glass", - "Sumi-e", - "Film Grain", - "Unsharp Mask", - "Lens Flare", - ] { - assert!(has(ru, label), "Russian filter label missing: {label}"); - } - // Dynamic composed status templates. - assert_eq!( - fmt(tr(ru, "Step {step} ({id}) failed: {error}"), &[("step", "2"), ("id", "file.export"), ("error", "ok")]), - "Шаг 2 (file.export) не выполнен: ok" - ); - assert_eq!(fmt(tr(ru, "Couldn't open {name}: {error}"), &[("name", "scan.tif"), ("error", "denied")]), "Не удалось открыть scan.tif: denied"); - assert_eq!(fmt(tr(ru, "Exported {path} ({size})"), &[("path", "/tmp/a.png"), ("size", "12 KB")]), "Экспортировано: /tmp/a.png (12 KB)"); - assert_eq!(fmt(tr(ru, "Data Set: {name} ({index}/{total})"), &[("name", "Set 1"), ("index", "1"), ("total", "2")]), "Набор данных: Set 1 (1/2)"); - // Document and layer names stay verbatim. - let text = fmt(tr(ru, "Saved {path}; image assets: {n} in {dir}"), &[("path", "/tmp/a.pcraft"), ("n", "3"), ("dir", "/tmp/assets")]); - assert!(text.contains("/tmp/a.pcraft") && text.contains("/tmp/assets"), "{text}"); - } - - /// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus - /// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as - /// they are (names, units) are listed in `KEEP_AS_IS`. - #[test] - fn every_tl_literal_is_translated() { - const KEEP_AS_IS: &[&str] = &[]; - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut literals = std::collections::BTreeSet::new(); - let mut stack = vec![dir]; - while let Some(d) = stack.pop() { - for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if path.extension().is_some_and(|e| e == "rs") && !path.ends_with("lib.rs") { - let text = std::fs::read_to_string(&path).unwrap_or_default().replace("\r\n", "\n"); - // Test modules aside, scan every `tl!("…")`. Each test *module* is cut out - // on its own: cutting the file at the first one hid everything after a small - // test module early in a file (panels.rs has one at ~line 540). A - // `#[cfg(test)]` on a single item doesn't count. - let code = without_test_modules(&text); - let mut rest = code.as_str(); - while let Some(at) = rest.find("tl!(\"") { - rest = &rest[at + 5..]; - let mut end = 0; - let bytes = rest.as_bytes(); - while end < bytes.len() && !(bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\')) { - end += 1; - } - let lit = rest.get(..end).unwrap_or("").replace("\\\"", "\""); - if rest.get(end + 1..end + 2) == Some(")") { - literals.insert(lit); - } - } - } - } - } - assert!(literals.len() > 300, "scan found only {} literals", literals.len()); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let cat = l.catalog(); - let missing: Vec<_> = literals.iter().filter(|s| !KEEP_AS_IS.contains(&s.as_str()) && cat.plain(s).is_none()).collect(); - assert!(missing.is_empty(), "{}: untranslated tl! strings: {missing:#?}", l.code); - } - } - - /// Section names are dynamic labels, so the literal scanner cannot cover them. - #[test] - fn brush_section_names_are_translated() { - for lang in Lang::all().filter(|l| l.complete_menus()) { - for (name, _) in crate::brush_panel::SECTIONS { - assert!(lang.0.catalog().plain(name).is_some(), "{} missing brush section: {name}", lang.code()); - } - } - } - - /// `menus::reveal_label` picks one name per platform, so every name needs a translation - /// whichever OS runs the tests (UI-217-19). - #[test] - fn reveal_labels_are_translated() { - for lang in Lang::all().filter(|l| l.complete_menus()) { - let cat = lang.0.catalog(); - for label in ["Reveal in Finder", "Show in Explorer", "Show in Folder"] { - let tr = cat.plain(label); - assert!(tr.is_some(), "{} missing {label}", lang.code()); - if label != "Reveal in Finder" { - assert!(!tr.unwrap_or_default().contains("Finder"), "{}: {label} names the Finder", lang.code()); - } - } - } - } - - #[test] - fn layer_color_names_are_translated() { - for lang in Lang::all().filter(|l| l.complete_menus()) { - for color in photocraft_doc::LabelColor::ALL { - let cat = lang.0.catalog(); - assert!( - cat.contextual("layerLabel", color.label()).or_else(|| cat.plain(color.label())).is_some(), - "{} missing {}", - lang.code(), - color.label() - ); - } - } - let de = lang_from_tag("de").unwrap(); - assert_eq!(tr_ctx(de, "layerLabel", "No Color"), "Keine Farbe"); - assert_eq!(tr_ctx(de, "layerLabel", "Seafoam"), "Meeresschaum"); - assert_eq!(tr(Lang::EN, "Seafoam"), "Seafoam"); - } - - /// These labels are passed to `tl!` dynamically, so the literal coverage scan misses them. - #[test] - fn russian_dynamic_selection_theme_and_measurement_labels_are_translated() { - let ru = Lang::from_code("ru").expect("ru registered"); - let (inverse_label, _) = - crate::canvas_tool_menu::SELECTION_MENU.iter().flatten().find(|(_, id)| *id == "select.inverse").expect("selection menu has Select Inverse"); - assert_eq!(tr(ru, inverse_label), "Инвертировать выделение"); - for kind in [crate::theme::ThemeKind::ProLight, crate::theme::ThemeKind::ProLightGray] { - assert!(has(ru, kind.label()), "Russian theme label missing {}", kind.label()); - } - for (_, label) in photocraft_engine::analysis_cmds::COLUMNS { - assert!(has(ru, label), "Russian measurement header missing {label}"); - } - assert_eq!(tr(ru, "Pro (Light)"), "Pro (светлая)"); - assert_eq!(tr(ru, "Pro (Light Gray)"), "Pro (светло-серая)"); - assert_eq!(tr(ru, "Date and Time"), "Дата и время"); - assert_eq!(tr(ru, "Integrated Density"), "Интегральная плотность"); - } - - /// Font style labels are built from dynamic words (weights, "Italic"), so the literal - /// scanner cannot cover them; "Light" there is a weight, distinct from the Camera Raw - /// "Light" section (`type_tool::style_label`). - #[test] - fn font_weight_names_are_translated() { - const TERMS: &[&str] = &["Thin", "ExtraLight", "Light", "Regular", "Medium", "SemiBold", "Bold", "ExtraBold", "Black", "Italic"]; - for lang in Lang::all().filter(|l| l.complete_menus()) { - for t in TERMS { - assert!(lang.0.catalog().contextual("fontWeight", t).is_some(), "{} missing font weight: {t}", lang.code()); - } - assert_ne!(tr_ctx(lang, "cameraRaw", "Light"), tr_ctx(lang, "fontWeight", "Light"), "{}: Camera Raw Light vs the font weight", lang.code()); - } - } - - /// Blend mode names come from the colour crate; each must be translated. - #[test] - fn blend_mode_names_are_translated() { - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - for m in std::iter::once(photocraft_color::BlendMode::PassThrough).chain(photocraft_color::BlendMode::layer_modes()) { - assert!(l.catalog().plain(m.label()).is_some(), "{}: blend mode {:?}", l.code, m.label()); - } - } - } - - /// The Italian catalog names the Dodge/Burn/Sponge *tools* and the blend modes the way Adobe - /// does. `Scherma` is the Dodge tool (with `Brucia` and `Spugna`) **and** the `Screen` blend - /// mode — Adobe's own collision, not ours — while `Schiarisci` is the `Lighten` blend mode and - /// must not be used for the tool. `Screen` used to read `Schermo`, "the display". - #[test] - fn italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does() { - let it = LANGUAGES.iter().find(|l| l.code == "it").expect("the Italian catalog is registered"); - for (source, translation) in [("Dodge", "Scherma"), ("Dodge Tool", "Strumento Scherma"), ("Dodge/Burn", "Scherma/Brucia"), ("Screen", "Scherma")] { - assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); - } - // The tool family stays together, and the blend modes keep Adobe's wording. - for (source, translation) in - [("Burn", "Brucia"), ("Sponge", "Spugna"), ("Lighten", "Schiarisci"), ("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")] - { - assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); - } - } - - // Dynamic dialog labels generated by label_of() are not seen by the tl! scanner. - #[test] - fn color_lookup_export_scope_labels_are_translated() { - for lang in Lang::all().filter(|l| *l != Lang::EN) { - for source in ["Scope", "Selected"] { - assert!(lang.catalog().plain(source).is_some(), "{}: missing LUT export label {source:?}", lang.code()); - assert_ne!(tr(lang, source), source, "{}: untranslated LUT export label {source:?}", lang.code()); - } - } - } - - #[test] - fn mixer_brush_ui_strings_have_translations_in_every_registered_language() { - const STRINGS: &[&str] = &[ - "Mixer Brush", - "Mixer Brush Tool", - "Wet", - "Load", - "Mix", - "Flow", - "Sample All Layers", - "Current Brush Load", - "Load Brush", - "Clean Brush", - "Load the brush after each stroke", - "Clean the brush after each stroke", - ]; - for lang in Lang::all() { - for source in STRINGS { - let translated = tr(lang, source); - if lang == Lang::EN { - assert_eq!(translated, *source, "English source string {source}"); - } else { - assert_ne!(translated, *source, "{} is missing {source:?}", lang.code()); - } - } - } - } - - #[test] - fn pattern_stamp_ui_strings_have_translations_in_every_registered_language() { - const STRINGS: &[&str] = &["Pattern Stamp Tool", "Pattern Stamp", "Impressionist", "Aligned"]; - for lang in Lang::all() { - for source in STRINGS { - let translated = tr(lang, source); - if lang == Lang::EN { - assert_eq!(translated, *source, "English source string {source}"); - } else { - assert_ne!(translated, *source, "{} is missing {source:?}", lang.code()); - } - } - } - } - - /// Camera Raw includes dynamic colour-band labels and contextual labels that the generic - /// tl! scanner cannot see. Cover the partial catalog too, without claiming whole-app coverage. - #[test] - fn camera_raw_labels_are_translated_in_every_available_language() { - let sources = [include_str!("../camera_raw_ui.rs"), include_str!("../camera_raw_scope_ui.rs")]; - let mut labels = std::collections::BTreeSet::new(); - for source in sources { - let code = source.split("#[cfg(test)]").next().unwrap(); - for marker in ["tl!(\"", "row(ui, &mut dirty, \"", "row(ui, dirty, \"", "section(ui, \"", "wheel(ui, &mut dirty, \"", "=> \""] { - for tail in code.split(marker).skip(1) { - labels.insert(tail.split('"').next().unwrap()); - } - } - } - let bands = sources[0].split("const BANDS:").nth(1).unwrap().split(" = ").nth(1).unwrap().split(';').next().unwrap(); - for band in bands.split('"').skip(1).step_by(2) { - labels.insert(band); - } - assert!(labels.len() >= 66, "missing Camera Raw source labels: {labels:?}"); - for lang in Lang::all().filter(|l| *l != Lang::EN) { - let catalog = lang.catalog(); - let missing: Vec<_> = labels.iter().filter(|s| catalog.contextual("cameraRaw", s).or_else(|| catalog.plain(s)).is_none()).collect(); - assert!(missing.is_empty(), "{}: Camera Raw labels: {missing:?}", lang.code()); - assert_ne!(tr_ctx(lang, "cameraRaw", "Highlights"), tr_ctx(lang, "cameraRaw", "Lights"), "{}: distinct curve regions", lang.code()); - assert_ne!(tr_ctx(lang, "cameraRaw", "Shadows"), tr_ctx(lang, "cameraRaw", "Darks"), "{}: distinct curve regions", lang.code()); - } - let ru = Lang::from_code("ru").unwrap(); - assert_eq!(tr_ctx(ru, "cameraRaw", "Vibrance"), "Красочность"); - assert_eq!(tr_ctx(ru, "cameraRaw", "Aqua"), "Голубые"); - let text = fmt(tr(ru, "Camera Raw Filter ({layer})"), &[("layer", "{Background} 影像")]); - assert_eq!(text, "Фильтр Camera Raw ({Background} 影像)", "user layer names are not translated"); - } } #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a615448afd..b24097f29d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -798,6 +798,7 @@ Split Warp Vertically Verbuiging verticaal splitsen Statistics… Statistieken… Refine Strokes Verfijningsstreken + Edits Bewerkingen @plural {n} item|{n} items {n} element|{n} elementen Auto Automatisch (print to the printer) (naar de printer afdrukken) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9bf57774c9..f6334bbc51 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -815,6 +815,7 @@ Split Warp Vertically Podziel wypaczenie w pionie Statistics… Statystyki… Refine Strokes Pociągnięcia udoskonalania + Edits Edycje # Counted status and panel messages (plural forms: 1 | 2–4 except 12–14 | the rest) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Wyeksportowano {n} próbkę koloru do {file}|Wyeksportowano {n} próbki kolorów do {file}|Wyeksportowano {n} próbek kolorów do {file} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Zaimportowano {n} próbkę koloru z {file}|Zaimportowano {n} próbki kolorów z {file}|Zaimportowano {n} próbek kolorów z {file} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d7ae51a4e5..ce433e2010 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -799,6 +799,7 @@ Split Warp Vertically Dividir deformação verticalmente Statistics… Estatísticas… Refine Strokes Refinar traços + Edits Edições @plural {n} item|{n} items {n} item|{n} itens Auto Automático (print to the printer) (imprimir na impressora) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f1d2b248df..ef957f3408 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -336,6 +336,7 @@ Current & Below Текущий и ниже Current Layer Текущий слой Refine Strokes Штрихи уточнения + Edits Правки # Filter Last Filter Последний фильтр diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9b5fc19f06..73e25758a9 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -790,6 +790,7 @@ Split Warp Vertically Podeli krivljenje vertikalno Statistics… Statistika… Refine Strokes Doteraj poteze + Edits Izmene @plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki Auto Automatski (print to the printer) (štampaj na štampaču) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 99c788a4ca..5b5a1e71ff 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -782,6 +782,7 @@ Split Warp Vertically Çözgüyü Dikey Olarak Böl Statistics… İstatistikler… Refine Strokes Fırça Vuruşlarını Hassaslaştır + Edits Düzenlemeler @plural {n} item|{n} items {n} öğe Auto Otomatik (print to the printer) (yazıcıya yazdır) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index febf6d0f2b..82540b4754 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -314,6 +314,7 @@ Current & Below Поточний і нижчі Current Layer Поточний шар Refine Strokes Штрихи уточнення + Edits Правки # Filter Last Filter Останній фільтр diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fc2314d2fe..ddef986df6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3596,6 +3596,7 @@ cameraData Lens Ống kính Decontaminate Khử nhiễm màu Refine Edge Tinh chỉnh cạnh Refine Strokes Tinh chỉnh nét + Edits Chỉnh sửa New Document With Mask Tài liệu mới có mặt nạ New Layer With Mask Lớp mới có mặt nạ Convert Point Tool Công cụ Chuyển đổi điểm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1dc744532d..61778561ce 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -823,6 +823,7 @@ Split Warp Vertically 垂直分割变形 Statistics… 统计… Refine Strokes 调整描边 + Edits 编辑 # Counted status and panel messages @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已将 {n} 个色板导出到 {file} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已从 {file} 导入 {n} 个色板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2f83349dba..dd05b6ad5b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -828,6 +828,7 @@ Split Warp Vertically 垂直分割彎曲 Statistics… 統計資料… Refine Strokes 調整筆畫 + Edits 編輯 @plural {n} item|{n} items {n} 個項目 Auto 自動 (print to the printer) (列印至印表機) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2191d2ac0a..dcb53d450a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1932,7 +1932,7 @@ mod tests { let items = menu_items(&app); let Some(fr) = crate::i18n::Lang::from_code("fr") else { return }; let ids = |q: &str| search_items(&items, q, fr).iter().map(|i| i.id.clone()).collect::>(); - assert!(!ids("calque").is_empty(), "French menu name"); + assert!(!ids(&crate::i18n::tr(fr, "Layer").to_lowercase()).is_empty(), "French menu name"); assert!(ids("distort").contains(&"edit.transform.distort".to_string()), "English still matches"); assert!(ids("zzzzqqq").is_empty()); } @@ -2261,7 +2261,7 @@ mod quick_export_label_tests { assert_eq!(quick[0].label, format!("Quick Export as {shown_format}")); let id = crate::i18n::Lang::from_code("id").unwrap(); let localized = translated_menu_label(id, quick[0]); - assert_eq!(localized, format!("Ekspor Cepat ke {shown_format}")); + assert_eq!(localized, crate::i18n::tr_id(id, "file.export.quickExportAsPng", "Quick Export as PNG").replace("PNG", shown_format)); let layer = items.iter().find(|i| i.id == "layer.quickExportAsPng").unwrap(); assert_eq!(layer.label, "Quick Export as PNG", "layer export always creates PNG"); } @@ -2299,12 +2299,7 @@ mod reveal_label_tests { let shortcuts = crate::prefs_ui::shortcut_items(&app); assert!(shortcuts.iter().any(|(i, label, _, _)| i == id && label == reveal_label()), "the shortcut list shows the menu label"); let de = crate::i18n::Lang::from_code("de").unwrap(); - let expected = match reveal_label() { - "Reveal in Finder" => "Im Finder anzeigen", - "Show in Explorer" => "Im Explorer anzeigen", - _ => "Im Ordner anzeigen", - }; - assert_eq!(translated_menu_label(de, reveal[0]), expected); + assert_eq!(translated_menu_label(de, reveal[0]), crate::i18n::tr(de, reveal_label())); // The menu catalogue (and docs/parity-checklist.md) keeps Photoshop's macOS name. assert!(crate::menu_catalog::CATALOG.iter().any(|(_, label, _, i)| *i == id && *label == "Reveal in Finder")); } @@ -2380,7 +2375,7 @@ mod window_documents_tests { let bar = crate::native_menu::photocraft_layout(&items, de, "de").bar; assert_eq!(bar.find("window.document.0").map(|i| i.label.as_str()), Some("Layers")); let panel = items.iter().find(|i| i.path == ["Window"] && i.label == "Layers").unwrap(); - assert_eq!(bar.find(&panel.id).map(|i| i.label.as_str()), Some("Ebenen"), "Window › Layers is still translated"); + assert_eq!(bar.find(&panel.id).map(|i| i.label.clone()), Some(translated_menu_label(de, panel)), "Window › Layers is still translated"); } /// The rows go through the control channel's `ui.menu.invoke`, like a click. diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 4f09d6a028..563a9a5fca 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -1029,23 +1029,6 @@ mod tests { out.textures_delta.clear(); } - #[test] - fn new_document_depth_labels_and_tooltips_are_translated() { - // Bit-depth labels are unit/number strings that some languages legitimately write exactly - // like the English source (Turkish and Italian "8 bit"); they only need a catalog entry there. - const SAME_AS_SOURCE: &[(&str, &str)] = &[("tr", "8 bit"), ("tr", "16 bit"), ("it", "8 bit"), ("it", "16 bit")]; - for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") { - for (_, label, tooltip) in DEPTH_OPTIONS { - if SAME_AS_SOURCE.contains(&(lang.code(), *label)) { - assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code()); - } else { - assert_ne!(crate::i18n::tr(lang, label), *label, "{}: {label}", lang.code()); - } - assert_ne!(crate::i18n::tr(lang, tooltip), *tooltip, "{}: {tooltip}", lang.code()); - } - } - } - #[test] fn recent_category_lists_clipboard_then_created_documents_then_uncovered_defaults() { let settings = |w, h, ppi| DocumentSettings { diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 08de9f16ab..79e4dad3db 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -293,14 +293,9 @@ mod tests { let command = Some("WAYLAND_DISPLAY= photocraft".to_string()); let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() }); let notice = &app.ui.notices[0]; - assert!(notice.lines.iter().all(|line| crate::i18n::tr(crate::i18n::Lang::from_code("es").unwrap(), line) != line), "every line is a catalog template"); // the notice keeps its English source text and is translated when drawn (`i18n::t` with - // the current language); `tr` with an explicit language leaves the process-wide language, - // which other tests running in parallel draw with, untouched - let es = crate::i18n::Lang::from_code("es").unwrap(); - assert_eq!(crate::i18n::tr(es, ¬ice.title), "Arrastrar y soltar archivos de forma nativa no está disponible en Wayland"); - assert_ne!(crate::i18n::tr(es, ¬ice.lines[0]), notice.lines[0]); - assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, ¬ice.title), "Native file drag-and-drop is unavailable"); + // the current language) + assert_eq!(notice.title, "Native file drag-and-drop is unavailable"); } /// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to @@ -325,8 +320,6 @@ mod tests { assert_eq!(shown.len(), 1, "a second paste doesn't stack another notice"); let guidance = english(shown[0]); assert!(guidance.contains(&format!("under XWayland: `{command}`")), "{guidance}"); - let es = crate::i18n::Lang::from_code("es").unwrap(); - assert!(shown[0].lines.iter().chain([&shown[0].title]).all(|line| crate::i18n::tr(es, line) != *line), "every line is a catalog template"); let mut empty = PhotocraftApp::new(Session::new(), services(false)); crate::menus::invoke(&mut empty, &ctx, "edit.paste", serde_json::json!({})).unwrap(); @@ -440,15 +433,16 @@ mod tests { #[test] fn import_warning_punctuation_is_localized_without_changing_english() { let mut app = PhotocraftApp::new(Session::new(), Services::default()); - for (lang, expected) in [ - (crate::i18n::Lang::EN, "Opened x.psd with 3 warnings: first warning …"), - (crate::i18n::Lang::from_code("cs").unwrap(), "Otevřeno: x.psd (3 upozornění): first warning …"), - ] { + for lang in [crate::i18n::Lang::EN, crate::i18n::Lang::from_code("cs").unwrap()] { crate::i18n::with_language(lang, || { let what = crate::i18n::fmt(tl!("Opened {name}"), &[("name", "x.psd")]); let warnings = vec!["first warning".into(), "second warning".into(), "third warning".into()]; io_warnings(&mut app, &what, &warnings); - assert_eq!(app.ui.status, expected); + if lang == crate::i18n::Lang::EN { + assert_eq!(app.ui.status, "Opened x.psd with 3 warnings: first warning …"); + } + let title = crate::i18n::fmt(&crate::i18n::trn(lang, 3, "{what} with {n} warning", "{what} with {n} warnings"), &[("what", &what)]); + assert_eq!(app.ui.status, format!("{title}: first warning …")); assert_eq!(app.ui.notices.last().unwrap().lines, warnings); io_warnings(&mut app, &what, &warnings[..1]); assert_eq!(app.ui.status, format!("{what}: first warning")); diff --git a/crates/ui-egui/src/plugin_ui.rs b/crates/ui-egui/src/plugin_ui.rs index 2b3a7f88c4..b581e6b423 100644 --- a/crates/ui-egui/src/plugin_ui.rs +++ b/crates/ui-egui/src/plugin_ui.rs @@ -103,9 +103,6 @@ mod tests { #[test] fn plugin_menu_path_stays_untranslated_in_other_languages() { let ja = crate::i18n::Lang::from_code("ja").expect("registered language"); - // The names on the path must really differ from the English, or the test proves nothing. - assert_ne!(crate::i18n::tr(ja, "Filter"), "Filter"); - assert_ne!(crate::i18n::tr(ja, "Plug-ins"), "Plug-ins"); let _lang = crate::i18n::language_scope(ja); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm"); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index f9179e7a2c..563c0023fd 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3273,34 +3273,6 @@ mod tests { } } - /// Every generated preference label, section title and choice label has an entry in each - /// language that claims complete menus (Japanese, Traditional Chinese, ...). - #[test] - fn preference_labels_are_translated() { - let session = photocraft_engine::Session::new(); - let v: Value = serde_json::from_str(&session.prefs_to_json()).expect("prefs json"); - for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) { - let mut missing = Vec::new(); - for (sec, title) in SECTIONS { - if !crate::i18n::has(lang, title) { - missing.push(title.to_string()); - } - let Some(obj) = v.get(sec).and_then(Value::as_object) else { continue }; - for k in obj.keys() { - if sec == "interface" && k == "favoriteThemes" { - continue; // Rendered as star controls beside the theme choices. - } - let mut labels = vec![humanize(k)]; - labels.extend(prefs::choices(&format!("{sec}.{k}")).into_iter().flatten().map(|c| choice_label(c))); - missing.extend(labels.into_iter().filter(|l| !crate::i18n::has(lang, l))); - } - } - missing.sort(); - missing.dedup(); - assert!(missing.is_empty(), "{}: untranslated preference labels: {missing:#?}", lang.code()); - } - } - fn app_with_store() -> (PhotocraftApp, Arc>>) { app_with_saved(None) } diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index fea5ac318a..49533350af 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -223,8 +223,8 @@ fn pattern_texture(ctx: &egui::Context, pat: &photocraft_doc::Pattern) -> Textur cached_texture(ctx, ("pattern", pat.id.clone()), || { let (w, h) = (pat.width.max(1), pat.height.max(1)); let fmt = pat.surface.format(); - let px = pat.surface.read_region(photocraft_geom::Rect::new(0, 0, w as i32, h as i32)); - let n = fmt.channels(); + // Only the pixels the swatch shows are read: a pattern file can declare a padded source + // of any size, and reading it whole to pick 4096 of its pixels is gigabytes of floats. // Tiles smaller than the swatch repeat; larger ones are shown shrunk. const S: u32 = 64; let step = (w.max(h) as f32 / S as f32).max(1.0); @@ -233,8 +233,8 @@ fn pattern_texture(ctx: &egui::Context, pat: &photocraft_doc::Pattern) -> Textur for x in 0..S { let sx = ((x as f32 * step) as u32) % w; let sy = ((y as f32 * step) as u32) % h; - let i = (sy * w + sx) as usize * n; - img.pixels[(y * S + x) as usize] = c32(photocraft_raster::to_rgba(&fmt, &px[i..i + n])); + let px = pat.surface.read_region(photocraft_geom::Rect::from_xywh(sx as i32, sy as i32, 1, 1)); + img.pixels[(y * S + x) as usize] = c32(photocraft_raster::to_rgba(&fmt, &px)); } } img diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 42053729f4..651a93b3aa 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -144,32 +144,8 @@ fn files_opened_together_ask_one_at_a_time_before_each_opens() { #[test] fn the_prompt_is_translated_and_still_names_its_file() { let png = adobe_png(); - for (code, sentence, use_embedded, convert) in [ - ( - "en", - "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.", - "Use the embedded profile (Adobe RGB", - "Convert to the working space (sRGB", - ), - ( - "de", - "Das Dokument „shot {mode}.png“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen RGB-Arbeitsfarbraum passt.", - "Eingebettetes Profil verwenden (Adobe RGB", - "In den Arbeitsfarbraum konvertieren (sRGB", - ), - ( - "fr", - "Le document «NBSPshot {mode}.pngNBSP» a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail RVB actuel.", - "Utiliser le profil incorporé (Adobe RGB", - "Convertir vers l'espace de travail (sRGB", - ), - ( - "ja", - "ドキュメント「shot {mode}.png」の埋め込みカラープロファイルは、現在の RGB 作業用スペースと一致しません。", - "埋め込みプロファイルを使用 (Adobe RGB", - "作業用スペースに変換 (sRGB", - ), - ] { + let template = "The document “{name}” has an embedded color profile that does not match the current {mode} working space."; + for code in ["en", "de", "fr", "ja"] { let services = Services { import: Some(Box::new(|name, bytes, opts| { photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) @@ -183,16 +159,14 @@ fn the_prompt_is_translated_and_still_names_its_file() { assert!(app.session.documents().is_empty(), "{code}: held until answered"); let fields = app.ui.dialogs[0].fields.clone(); let message = fields.get("message").and_then(Value::as_str).unwrap().to_string(); - assert_eq!(message, sentence.replace("NBSP", "\u{a0}"), "{code}: one line, the profiles are on the choices"); + let expected = crate::i18n::fmt(crate::i18n::tr(lang, template), &[("mode", crate::i18n::tr(lang, "RGB")), ("name", "shot {mode}.png")]); + assert_eq!(message, expected, "{code}: one line, the profiles are on the choices"); + if code == "en" { + assert_eq!(message, "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space."); + } let name = |k: &str| fields.get(k).and_then(Value::as_str).unwrap().to_string(); - let (use_label, convert_label) = crate::i18n::with_language(lang, || { - ( - crate::i18n::fmt(tl!("Use the embedded profile ({profile})"), &[("profile", &name("embedded"))]), - crate::i18n::fmt(tl!("Convert to the working space ({profile})"), &[("profile", &name("working"))]), - ) - }); - assert!(use_label.starts_with(use_embedded), "{code}: {use_label}"); - assert!(convert_label.starts_with(convert), "{code}: {convert_label}"); + assert!(name("embedded").starts_with("Adobe RGB"), "{code}: {}", name("embedded")); + assert!(name("working").starts_with("sRGB"), "{code}: {}", name("working")); } } diff --git a/crates/ui-egui/src/served_fonts.rs b/crates/ui-egui/src/served_fonts.rs index 6273383c75..b7ae085f41 100644 --- a/crates/ui-egui/src/served_fonts.rs +++ b/crates/ui-egui/src/served_fonts.rs @@ -5,7 +5,7 @@ use std::sync::{Mutex, PoisonError}; use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer}; -pub use photocraft_text::served::{ServedFont, add_families, add_fonts, parse_manifest, request_for_languages, take_requests}; +pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests}; use crate::PhotocraftApp; diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 9bb0e5df24..df66185db1 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -310,9 +310,11 @@ fn a_disabled_shortcut_says_why() { #[test] fn a_disabled_shortcut_uses_the_command_id_translation() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - crate::i18n::with_language(crate::i18n::Lang::from_code("cs").unwrap(), || { + let cs = crate::i18n::Lang::from_code("cs").unwrap(); + crate::i18n::with_language(cs, || { super::dispatch(&mut app, &egui::Context::default(), "select.all"); - assert!(app.ui.status.starts_with("Vybrat vše není k dispozici:"), "{}", app.ui.status); + let shown = crate::i18n::tr_id(cs, "select.all", &super::label("select.all")).to_string(); + assert!(app.ui.status.contains(&shown), "{}", app.ui.status); assert!(app.ui.status_error); }); } diff --git a/crates/ui-egui/src/stroke_ui_tests.rs b/crates/ui-egui/src/stroke_ui_tests.rs index b79306c3a3..1ef75e4d58 100644 --- a/crates/ui-egui/src/stroke_ui_tests.rs +++ b/crates/ui-egui/src/stroke_ui_tests.rs @@ -139,17 +139,6 @@ fn command_params_do_not_leak_private_dialog_fields() { assert_eq!(out["color"], "#ff0000"); } -#[test] -fn stroke_dialog_labels_are_translated_in_every_catalog() { - const LABELS: &[&str] = - &["Stroke", "Width:", "Color:", "Location", "Location:", "Inside", "Center", "Outside", "Blending", "Mode:", "Opacity:", "Preserve Transparency"]; - for lang in crate::i18n::Lang::all().filter(|lang| lang.complete_menus()) { - for label in LABELS { - assert!(crate::i18n::has(lang, label), "{} is missing Stroke dialog label: {label}", lang.code()); - } - } -} - /// The Stroke dialog's colour swatch opens PhotoCraft's Color Picker on the stroke colour (as in /// Photoshop), not egui's compact popup; OK sets the stroke colour, not the foreground, and the /// stroke is drawn in it. diff --git a/crates/ui-egui/src/symmetry_ui/tests.rs b/crates/ui-egui/src/symmetry_ui/tests.rs index 9f66449572..85fd45b0a3 100644 --- a/crates/ui-egui/src/symmetry_ui/tests.rs +++ b/crates/ui-egui/src/symmetry_ui/tests.rs @@ -184,9 +184,6 @@ fn overlays_follow_document_view_transforms_in_every_theme() { } out.textures_delta.clear(); } - let de = crate::i18n::Lang::from_code("de").unwrap(); - assert_eq!(crate::i18n::tr(de, "Dual Axis"), "Duale Achse"); - assert_eq!(crate::i18n::tr(de, "Transform Symmetry"), "Symmetrie transformieren"); } /// #4495: while the symmetry is transformed the options bar has ✓ and ⊘ (Enter and Esc were the /// only ways out), and the canvas shows the move, rotate or arrow cursor instead of the brush. diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index ddb3bc42a6..da8915aced 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -65,7 +65,7 @@ fn numeric_type_fields_have_localized_accessible_names() { ); PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); - h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tamanho da fonte").click(); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, crate::i18n::tr(pt, "Font size")).click(); h.run(); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("36".into())); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d7ab90b2e9..223d0cfa8e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -2367,7 +2367,8 @@ mod tests { #[test] fn arrow_keys_on_a_field_with_a_reversed_range_do_not_panic() { - let mut h = focused(5.0, 10.0..=0.0); + // The empty range is the case under test — arrow keys must not panic on it. + let mut h = focused(5.0, 10.0..=0.0); // NOSONAR let _ = press(&mut h, Modifiers::NONE, Key::ArrowUp); let mut h = focused(5.0, f32::NAN..=10.0); let _ = press(&mut h, Modifiers::NONE, Key::ArrowDown); diff --git a/crates/ui-egui/tests/it/camera_raw_histogram.rs b/crates/ui-egui/tests/it/camera_raw_histogram.rs index 144522908d..7b1c9502d0 100644 --- a/crates/ui-egui/tests/it/camera_raw_histogram.rs +++ b/crates/ui-egui/tests/it/camera_raw_histogram.rs @@ -80,7 +80,6 @@ fn histogram_matches_visible_preview_at_all_supported_depths_and_cancel_commit_a #[test] fn settings_sections_group_existing_controls_without_rendering_or_document_edits() { - assert_eq!(photocraft_ui_egui::i18n::tr_ctx(photocraft_ui_egui::i18n::Lang::from_code("ru").unwrap(), "cameraRaw", "Light"), "Свет"); for theme in ThemeKind::ALL { let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, theme); diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index ac5fe8aad5..d6c21e3ac3 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -156,11 +156,14 @@ fn tile_bands(r: Rect) -> Vec { if r.is_empty() { return out; } - let mut y = r.y0.div_euclid(TILE_SIZE) * TILE_SIZE; - while y < r.y1 { - let band = Rect::new(r.x0, y.max(r.y0), r.x1, (y + TILE_SIZE).min(r.y1)); + let step = i64::from(TILE_SIZE); + let (y0, y1) = (i64::from(r.y0), i64::from(r.y1)); + let mut y = y0.div_euclid(step) * step; + while y < y1 { + let next = (y + step).min(y1); + let band = Rect::new(r.x0, y.max(y0) as i32, r.x1, next as i32); out.push(band); - y += TILE_SIZE; + y = next; } out } @@ -170,9 +173,15 @@ fn tile_bands(r: Rect) -> Vec { fn band_tiles(band: Rect, data: &[f32], channels: usize, keep: impl Fn(&[f32]) -> bool) -> Vec<(Rect, Vec)> { let w = band.width() as usize; let mut out = Vec::new(); - let mut x = band.x0.div_euclid(TILE_SIZE) * TILE_SIZE; - while x < band.x1 { - let tr = Rect::new(x.max(band.x0), band.y0, (x + TILE_SIZE).min(band.x1), band.y1); + if band.is_empty() { + return out; + } + let step = i64::from(TILE_SIZE); + let (x0, x1) = (i64::from(band.x0), i64::from(band.x1)); + let mut x = x0.div_euclid(step) * step; + while x < x1 { + let next = (x + step).min(x1); + let tr = Rect::new(x.max(x0) as i32, band.y0, next as i32, band.y1); let tw = tr.width() as usize; let mut buf = Vec::with_capacity(tw * tr.height() as usize * channels); let mut any = false; @@ -187,7 +196,7 @@ fn band_tiles(band: Rect, data: &[f32], channels: usize, keep: impl Fn(&[f32]) - if any { out.push((tr, buf)); } - x += TILE_SIZE; + x = next; } out } @@ -415,15 +424,13 @@ fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] { if t <= first.0 { return first.1; } - for (i, w) in stops.windows(2).enumerate() { - let (a, b) = (&w[0], &w[1]); - if t <= b.0 { - let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; - let k = midpoint_remap(u, mids.get(i).copied().unwrap_or(0.5)); - return std::array::from_fn(|ch| a.1[ch] + (b.1[ch] - a.1[ch]) * k); - } - } - last.1 + // The segment ending at the first stop at or past `t` (bisection: this runs per pixel, and a + // gradient can carry any number of stops). Past the last stop, or for a NaN, there is none. + let at = stops.partition_point(|s| s.0 < t); + let (Some(a), Some(b)) = (at.checked_sub(1).and_then(|k| stops.get(k)), stops.get(at)) else { return last.1 }; + let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + let k = midpoint_remap(u, mids.get(at - 1).copied().unwrap_or(0.5)); + std::array::from_fn(|ch| a.1[ch] + (b.1[ch] - a.1[ch]) * k) } /// Opacity stops at `t` (stops sorted by location; 1.0 when empty). @@ -432,13 +439,10 @@ fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 { if t <= first.0 { return first.1; } - for w in stops.windows(2) { - if t <= w[1].0 { - let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 }; - return w[0].1 + (w[1].1 - w[0].1) * u; - } - } - last.1 + let at = stops.partition_point(|s| s.0 < t); + let (Some(a), Some(b)) = (at.checked_sub(1).and_then(|k| stops.get(k)), stops.get(at)) else { return last.1 }; + let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + a.1 + (b.1 - a.1) * u } /// Colour and alpha at `t`: colour stops with midpoints, alpha times opacity stops. Mirrors diff --git a/crates/vector/src/raster.rs b/crates/vector/src/raster.rs index eb6c4a943b..1b0d5fd49b 100644 --- a/crates/vector/src/raster.rs +++ b/crates/vector/src/raster.rs @@ -19,6 +19,11 @@ use photocraft_geom::Rect; /// a 16-bit step (7.6e-6), so 8- and 16-bit output is unchanged by dropping it. const RESIDUE: f32 = 1e-6; +/// Maximum sub-band cuts within a single pixel row before adjacent cuts are coalesced, and +/// cumulative edge-evaluation budget per row to prevent quadratic work on degenerate inputs. +const MAX_ROW_CUTS: usize = 256; +const MAX_ROW_EDGE_WORK: usize = 262_144; + #[derive(Clone, Copy, Debug)] struct Edge { x0: f64, @@ -235,9 +240,36 @@ impl Rasterizer { } st.cuts.sort_by(f64::total_cmp); st.cuts.dedup(); + if st.cuts.len() > MAX_ROW_CUTS { + let step = (st.cuts.len() - 1).div_ceil(MAX_ROW_CUTS - 1); + let last = st.cuts[st.cuts.len() - 1]; + let mut thinned = Vec::with_capacity(MAX_ROW_CUTS); + for (idx, &c) in st.cuts.iter().enumerate() { + if idx % step == 0 { + thinned.push(c); + } + } + if thinned.last().copied() != Some(last) { + thinned.push(last); + } + st.cuts = thinned; + } + st.active.sort_unstable_by(|&a, &b| self.edges[a].y0.total_cmp(&self.edges[b].y0)); + st.sub_active.clear(); + st.work = 0; + let mut next_sub = 0usize; let cuts = std::mem::take(&mut st.cuts); for c in cuts.windows(2) { - self.band(c[0], c[1], rect.x0, &mut st, 0); + let (a, b) = (c[0], c[1]); + while next_sub < st.active.len() && self.edges[st.active[next_sub]].y0 < b { + let ei = st.active[next_sub]; + if self.edges[ei].y1 > a { + st.sub_active.push(ei); + } + next_sub += 1; + } + st.sub_active.retain(|&ei| self.edges[ei].y1 > a); + self.band(a, b, rect.x0, &mut st, 0); } st.cuts = cuts; let row_out = &mut out[row * w..(row + 1) * w]; @@ -259,13 +291,14 @@ impl Rasterizer { } st.band.clear(); let mid = 0.5 * (a + b); - for &i in &st.active { + for &i in &st.sub_active { let e = &self.edges[i]; if e.y0 <= mid && e.y1 >= mid { let (xa, xb) = (e.x_at(a), e.x_at(b)); st.band.push(BandEdge { xa, xb, xm: 0.5 * (xa + xb), idx: i as u32 }); } } + st.work = st.work.saturating_add(st.band.len()); if st.band.is_empty() { if self.inverted { add_full(&mut st.acc, b - a); @@ -275,7 +308,7 @@ impl Rasterizer { st.band.sort_by(|p, q| p.xm.total_cmp(&q.xm)); // An inversion of the order at the band's top or bottom means two edges cross inside // it: split there so the order is constant in every piece. - if depth < 24 { + if depth < 24 && st.work < MAX_ROW_EDGE_WORK { for k in 1..st.band.len() { let (p, q) = (st.band[k - 1], st.band[k]); let top = p.xa - q.xa; @@ -330,16 +363,26 @@ struct BandEdge { struct RowState { active: Vec, + sub_active: Vec, cuts: Vec, band: Vec, wind: Vec, /// Cell accumulator: `width + 1` cells (the extra one absorbs spill-over). acc: Vec, + work: usize, } impl RowState { fn new(comps: usize, width: usize) -> Self { - RowState { active: Vec::new(), cuts: Vec::new(), band: Vec::new(), wind: vec![0; comps.max(1)], acc: vec![0.0; width + 1] } + RowState { + active: Vec::new(), + sub_active: Vec::new(), + cuts: Vec::new(), + band: Vec::new(), + wind: vec![0; comps.max(1)], + acc: vec![0.0; width + 1], + work: 0, + } } } @@ -519,4 +562,23 @@ mod tests { assert_eq!(r.pixel_bounds(), Some(Rect::new(1, 2, 5, 6))); } + + #[test] + fn many_short_segments_in_single_row_complete_quickly() { + let mut r = Rasterizer::new(false); + let n = 4000; + let mut poly = Vec::with_capacity(n * 2); + for i in 0..n { + let y = 0.01 + 0.98 * (i as f64 / n as f64); + let x = if i % 2 == 0 { 1.0 } else { 3.0 }; + poly.push((x, y)); + } + poly.push((5.0, 0.99)); + poly.push((5.0, 0.01)); + r.add_component(&[poly], PathOp::Combine, FillRule::NonZero); + + let cov = r.render(Rect::new(0, 0, 8, 2)); + assert_eq!(cov.len(), 16); + assert!(cov.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + } } diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index dfeae26e81..d259d27871 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -142,7 +142,7 @@ pub fn fillet(prev: [f64; 2], v: [f64; 2], next: [f64; 2], r: f64) -> Option std::f64::consts::FRAC_PI_2 - 1e-6 { + if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-6).contains(&half) { return None; } let r = r.min(l1.min(l2) / 2.0 * half.tan()); diff --git a/crates/vector/src/tests.rs b/crates/vector/src/tests.rs index 18b2f6f18a..815c7be5e6 100644 --- a/crates/vector/src/tests.rs +++ b/crates/vector/src/tests.rs @@ -570,3 +570,47 @@ fn thin_antialiased_edges_still_count_toward_bounds() { let c = r.render(Rect::new(0, 0, 102, 10)); assert!(c[100] > 5e-5 && c[101] == 0.0, "{} {}", c[100], c[101]); } + +#[test] +fn tile_bands_and_band_tiles_near_i32_max_do_not_overflow() { + let r = Rect::new(0, i32::MAX - 10, 10, i32::MAX); + let bands = tile_bands(r); + assert_eq!(bands, vec![r]); + + let band = Rect::new(i32::MAX - 10, 0, i32::MAX, 2); + let data = vec![1.0f32; (band.width() * band.height()) as usize]; + let tiles = band_tiles(band, &data, 1, |p| p[0] > 0.0); + assert_eq!(tiles.len(), 1); + assert_eq!(tiles[0].0, band); +} + +#[test] +fn gradient_stops_are_found_by_bisection_and_agree_with_a_scan() { + let stops: Vec<(f32, [f32; 4])> = (0..=8).map(|i| (i as f32 / 8.0, [i as f32 / 8.0, 0.0, 1.0 - i as f32 / 8.0, 1.0])).collect(); + let scan = |t: f32| { + let (first, last) = (stops[0], stops[stops.len() - 1]); + if t <= first.0 { + return first.1; + } + for w in stops.windows(2) { + if t <= w[1].0 { + let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 }; + return std::array::from_fn(|ch| w[0].1[ch] + (w[1].1[ch] - w[0].1[ch]) * u); + } + } + last.1 + }; + for i in -10..=110 { + let t = i as f32 / 100.0; + assert_eq!(sample_color(&stops, &[], t), scan(t), "t = {t}"); + } + assert_eq!(sample_color(&stops, &[], f32::NAN), stops[8].1, "a NaN position takes the last stop, as before"); + assert_eq!(sample_opacity(&[(0.0, 0.0), (1.0, 1.0)], 0.25), 0.25); + // A million stops are bisected: this is a per-pixel call. + let many: Vec<(f32, [f32; 4])> = (0..1_000_000).map(|i| (i as f32 / 1_000_000.0, [0.0; 4])).collect(); + let started = std::time::Instant::now(); + for i in 0..100_000 { + sample_color(&many, &[], 0.9 + i as f32 * 1e-7); + } + assert!(started.elapsed().as_secs() < 5, "{:?}", started.elapsed()); +} diff --git a/crates/vector/src/trace.rs b/crates/vector/src/trace.rs index 1a547bfb0b..1557af863d 100644 --- a/crates/vector/src/trace.rs +++ b/crates/vector/src/trace.rs @@ -37,14 +37,26 @@ pub fn trace_mask(values: &[f32], rect: Rect, outside: f32, tolerance: f64) -> P /// Closed iso-contours at 0.5 through pixel centres. pub fn contours(values: &[f32], rect: Rect, outside: f32) -> Vec> { + if rect.is_empty() { + return Vec::new(); + } let w = rect.width() as i64; let h = rect.height() as i64; - let get = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w || y >= h { outside } else { values[(y * w + x) as usize] } }; + let Some(need) = (w as usize).checked_mul(h as usize) else { + return Vec::new(); + }; + if values.len() < need { + return Vec::new(); + } + let clean = |v: f32| if v.is_finite() { v } else { 0.0 }; + let outside = clean(outside); + let get = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w || y >= h { outside } else { clean(values[(y * w + x) as usize]) } }; // Edge ids: horizontal edge between (x,y)-(x+1,y) = (x, y, 0); vertical (x,y)-(x,y+1) = (x, y, 1). let lvl = 0.5f32; let point = |a: (i64, i64), b: (i64, i64)| -> P { let (va, vb) = (get(a.0, a.1), get(b.0, b.1)); - let t = if (vb - va).abs() > 1e-9 { f64::from((lvl - va) / (vb - va)).clamp(0.0, 1.0) } else { 0.5 }; + let raw_t = if (vb - va).abs() > 1e-9 { f64::from((lvl - va) / (vb - va)) } else { 0.5 }; + let t = if raw_t.is_finite() { raw_t.clamp(0.0, 1.0) } else { 0.5 }; let (ax, ay) = (a.0 as f64 + 0.5, a.1 as f64 + 0.5); let (bx, by) = (b.0 as f64 + 0.5, b.1 as f64 + 0.5); (f64::from(rect.x0) + ax + (bx - ax) * t, f64::from(rect.y0) + ay + (by - ay) * t) @@ -156,14 +168,18 @@ fn norm(a: P) -> P { /// an open polyline, where the walk stops at the ends. fn walk(lp: &[P], i: usize, window: f64, fwd: bool, closed: bool) -> P { let n = lp.len(); + let max_steps = n.min(256); let mut acc = 0.0; let mut cur = i; - for _ in 0..n { + for _ in 0..max_steps { if !closed && (if fwd { cur + 1 >= n } else { cur == 0 }) { break; } let nx = if fwd { (cur + 1) % n } else { (cur + n - 1) % n }; let d = len(sub(lp[nx], lp[cur])); + if !d.is_finite() || !window.is_finite() { + break; + } if acc + d >= window { let t = (window - acc) / d.max(1e-12); return add(lp[cur], mul(sub(lp[nx], lp[cur]), t)); @@ -178,6 +194,9 @@ fn walk(lp: &[P], i: usize, window: f64, fwd: bool, closed: bool) -> P { /// ends of an open polyline are never corners. fn corners(lp: &[P], window: f64, closed: bool) -> Vec { let n = lp.len(); + if n < 3 || !window.is_finite() || lp.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return Vec::new(); + } let cos_thresh = (60f64).to_radians().cos(); let turn: Vec = (0..n) .map(|i| { @@ -190,21 +209,33 @@ fn corners(lp: &[P], window: f64, closed: bool) -> Vec { }) .collect(); let mut out = Vec::new(); + let max_neighbor_steps = n.min(256); + let mut work_budget = n.saturating_mul(64).max(1024); for i in 0..n { if turn[i] >= cos_thresh { continue; } + if work_budget == 0 { + break; + } // Local minimum of the cosine within ±window (ties: first index). let mut is_min = true; let mut acc = 0.0; let mut j = i; - while acc < window { + let mut steps = 0usize; + while acc < window && steps < max_neighbor_steps && work_budget > 0 { if !closed && j + 1 >= n { break; } let nx = (j + 1) % n; - acc += len(sub(lp[nx], lp[j])); + let d = len(sub(lp[nx], lp[j])); + if !d.is_finite() { + break; + } + acc += d; j = nx; + steps += 1; + work_budget -= 1; if j == i { break; } @@ -215,13 +246,20 @@ fn corners(lp: &[P], window: f64, closed: bool) -> Vec { } acc = 0.0; j = i; - while is_min && acc < window { + steps = 0; + while is_min && acc < window && steps < max_neighbor_steps && work_budget > 0 { if !closed && j == 0 { break; } let pv = (j + n - 1) % n; - acc += len(sub(lp[j], lp[pv])); + let d = len(sub(lp[j], lp[pv])); + if !d.is_finite() { + break; + } + acc += d; j = pv; + steps += 1; + work_budget -= 1; if j == i { break; } @@ -239,6 +277,9 @@ fn corners(lp: &[P], window: f64, closed: bool) -> Vec { /// Fits a closed loop with cubic Béziers; `window` is the corner detection window. fn fit_closed(lp: &[P], tol: f64, window: f64) -> Subpath { let n = lp.len(); + if n < 3 || lp.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return Subpath { closed: true, knots: Vec::new(), op: PathOp::Combine }; + } let cs = corners(lp, window, true); let mut segs: Vec<[P; 4]> = Vec::new(); let mut corner_at_start: Vec = Vec::new(); @@ -617,4 +658,39 @@ mod tests { let sp = fit_trace(&long, -5.0, false).unwrap(); assert!(sp.knots.len() >= 2 && sp.knots.iter().all(|k| k.anchor.x.is_finite() && k.out_ctrl.y.is_finite())); } + + #[test] + fn corners_have_bounded_work_for_many_tiny_segments() { + let points: Vec

= (0..20_000).map(|i| (i as f64 * 0.01, if i % 2 == 0 { 0.0 } else { 1e-5 })).collect(); + let selected = corners(&points, 100.0, false); + assert!(selected.len() <= points.len()); + } + + #[test] + fn contours_reject_empty_rect_and_clean_non_finite_values() { + assert!(contours(&[], Rect::new(0, 0, 0, 1_000_000_000), 0.0).is_empty()); + assert!(contours(&[], Rect::new(0, 0, 1_000_000_000, 0), 0.0).is_empty()); + assert!(contours(&[1.0], Rect::new(0, 0, 10, 10), 0.0).is_empty()); + + let (w, h) = (6, 6); + let mut v = vec![0.0f32; w * h]; + for y in 1..5 { + for x in 1..5 { + v[y * w + x] = f32::INFINITY; + } + } + v[2 * w + 2] = f32::NAN; + v[w + 1] = 1.0; + v[w + 2] = 1.0; + v[2 * w + 1] = 1.0; + let path = trace_mask(&v, Rect::new(0, 0, w as i32, h as i32), f32::NAN, 1.0); + assert!(path.subpaths.iter().any(|sp| !sp.knots.is_empty())); + for sp in &path.subpaths { + for k in &sp.knots { + assert!(k.anchor.x.is_finite() && k.anchor.y.is_finite()); + assert!(k.in_ctrl.x.is_finite() && k.in_ctrl.y.is_finite()); + assert!(k.out_ctrl.x.is_finite() && k.out_ctrl.y.is_finite()); + } + } + } } diff --git a/docs/development.md b/docs/development.md index 4c4ee7be19..777ee766ee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -41,14 +41,15 @@ cargo run --release -p photocraft -- path/to/image.psd # desktop app cargo run --release -p photocraft -- --control 7878 --control-token-file .private/control.token \ --automation-read-root work --automation-write-root work cargo test --workspace # everything -cargo xtask ci # fmt + clippy + tests + layers + wasm +cargo xtask test # tests (nextest when installed) + doctests +cargo xtask ci # the CI gate: fmt, clippy, layers, scorecard, tests, panic_hunt, doctests, wasm, i18n report cargo xtask stats # tests and lines per crate cargo xtask parity # Photoshop menu coverage -> docs/parity-checklist.md ``` Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. Test builds compile ui-egui itself at `opt-level 0` (`[profile.test.package.photocraft-ui-egui]` in `Cargo.toml`): it was one of the biggest test-build units, and its tests run no heavy pixel code of their own. -The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. +The dev profile builds PhotoCraft's crates with `debug = "line-tables-only"` and dependencies with no debug info: panic backtraces keep file and line in PhotoCraft code, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. ### Faster test loops @@ -56,12 +57,12 @@ Most of a `cargo test` run is linking test binaries and running them one after a ```sh cargo test -p --lib --tests # skip examples and doctests (ui-egui has 11 examples) -cargo test -p --test # one integration-test file +cargo test -p --test it :: # one integration-test module (tests/it/.rs) cargo nextest run --workspace # parallel across binaries (cargo install cargo-nextest --locked) cargo test --doc --workspace # nextest does not run doctests ``` -[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. +[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. The longest sweeps (`photocraft-testkit`'s `Sweep`) run a quarter of their cases, a different quarter each UTC day; `PHOTOCRAFT_FULL_SWEEP=1` runs all of them, and `PHOTOCRAFT_SWEEP_PART=` replays the part a failing run printed. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); `PHOTOCRAFT_FULL_SWEEP=1 cargo test --workspace` stays the final check before a PR. ### Shortcut capture without a GPU, and native snapshots @@ -111,7 +112,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa - **UI:** the Japanese fonts (BIZ UDPGothic Regular first) are the first Japanese fallback in the lazy CJK loader (`crates/ui-egui/src/cjk_fonts.rs`), ahead of the system Japanese fonts and in the same locale script order, appended last to every egui family with the usual baseline alignment. - **Type tool:** the text engine registers them in `FontDb::new` (so also with no system fonts) and puts them first in the Japanese slot of the locale-ordered fallback list: BIZ UDPGothic for sans runs, Shippori Mincho / BIZ UDMincho for serif runs. - **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. Instead it loads fonts served next to it, on demand: `fonts/manifest.txt` beside `index.html`, in craft-fonts' manifest format (`crates/text/src/served.rs`; hosting: `packaging/web/README.md` › Fonts). `PHOTOCRAFT_WEB_FONTS_DIR="$PWD/../craft-fonts" packaging/web/package.sh` puts craft-fonts there. Served families are in the font menus and are fetched when picked or when a document's text needs them; they aren't in the script fallback list yet, so Japanese text set in a Latin font still needs a Japanese font picked (#1615). -- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI runs them again with `CRAFT_FONTS_DIR` set, in its own Linux job (`test (with craft-fonts)`). Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. +- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI's Linux gate builds with `CRAFT_FONTS_DIR` set and `CRAFT_FONTS_REQUIRED=1`, so they run there. Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. ## One instance per user @@ -332,7 +333,11 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity-checklis `crates/ui-egui/src/i18n/mod.rs` registry, in stable language-code order. Its English-key set is derived from UI `tl!` literals, the menu catalog and UI command table, and menu command labels in the engine source. Catalog rows are validated for malformed and duplicate keys; unused legacy -translations do not affect the denominator. Unregistered locale TSV files are ignored. +translations do not affect the denominator. Unregistered locale TSV files are ignored. After the +counts it lists, per language, the source keys the catalog lacks (one line for a language with +none). A missing translation shows in English and fails nothing: `cargo xtask ci` runs the report +as its last step so the list appears in the log, and only prints a warning if the report itself +can't run. ## Testing strategy diff --git a/docs/localization-es.md b/docs/localization-es.md index de47b78716..4212dcac71 100644 --- a/docs/localization-es.md +++ b/docs/localization-es.md @@ -26,8 +26,8 @@ Filter Gallery names, Camera Raw, Lighting Effects, History and Layers controls, dialogs, status wrappers and System Info labels. Before this pass, those controls fell back to English because many are assembled dynamically and cannot be found by the static coverage scan. -`spanish_covers_generated_filter_options_and_gallery_names` in `filter_dialog.rs` enumerates the -generated filter and adjustment surface and fails if a future label has no Spanish row. A few +A future generated label without a Spanish row shows in English. `cargo xtask i18n-coverage` +lists missing rows for the keys it scans, which don't include these generated labels. A few unused legacy labels are retained so the complete catalogs stay aligned. ## Terminology diff --git a/docs/localization-it.md b/docs/localization-it.md index f983c499e4..bb82fee489 100644 --- a/docs/localization-it.md +++ b/docs/localization-it.md @@ -52,9 +52,8 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast 2026-10-13: `Screen` was *Schermo* — the display, not the blend mode — and is now *Scherma*, the word Adobe uses. The Dodge/Burn/Sponge tools were left as they were (*Scherma*, *Brucia*, *Spugna*): a first pass also wanted to rename the Dodge tool to *Schiarisci*, but that is the `Lighten` blend -mode and would have given two different things the same name. The wording is pinned by a test -(`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`), which -also keeps the tool family together. +mode and would have given two different things the same name. Keep the tool family's names +together when editing these rows. 2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel & @@ -69,10 +68,8 @@ current code; they are translated to stay aligned with the other catalogs. ## Validation -- `italian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) - enumerates every filter and adjustment parameter, symbolic choice and gallery name, like the - Korean test, and requires an Italian row for each. With the catalog on `main` before this change - it lists 388 missing labels; now none. +- Every filter and adjustment parameter, symbolic choice and gallery name has an Italian row. The + catalog on `main` before this change lacked 388 of them. - `cargo test -p photocraft-ui-egui --lib`: the catalog tests pass. Two layout tests, `type_controls_fill_the_panel_at_every_width_and_scale` and `properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary`, fail on `main` as well diff --git a/docs/localization-ko.md b/docs/localization-ko.md index 0cf8fc97b0..3b21840305 100644 --- a/docs/localization-ko.md +++ b/docs/localization-ko.md @@ -23,14 +23,11 @@ resources, fonts, screenshots or user documents are included. Command-ID translations disambiguate menu names from actions. Photoshop terms are used when the operation matches; PhotoCraft-specific features retain their own meaning. Technology names, units and user-provided names are not translated. -The terminology test protects representative tool names and this distinction. ## Dynamic coverage Generated filter parameter names, symbolic choices, gallery categories/effects, job titles, layer-style menus and shortcut labels now go through the catalog. -The filter test enumerates engine filter/adjustment parameter specs and gallery -names to catch new Korean omissions that literal-only scans cannot find. Two newly introduced literals, `Enable {name}` and `No patterns`, are present in all nine non-English catalogs. Existing fallback behavior is preserved for untranslated dynamic labels in other languages. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9f5d6cd066..2ea5a9a0b6 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -13,7 +13,9 @@ labels and path segments, `menus.rs` UI commands, and engine `CommandSpec` label checked against that universe by a source script replicating the xtask (no build). Catalogs hold more rows than the universe because they also translate dynamic `tl!(expr)` strings (~159 call sites the tool can't enumerate). **Not covered anywhere:** engine error and status messages and -filter parameter help, which are English in every language; so no language is `full`. +filter parameter help, which are English in every language; so no language is `full`. The report +also lists, per language, the keys its catalog lacks; those show in English. `cargo xtask ci` prints +it as its last step and never fails on it. **Photoshop 2026** ships UI language packs for cs, da, de, en, es, fi, fr (and fr-CA), hu, it, ja, ko, nb, nl, pl, pt-BR, ru, sk, sv, tr, uk, zh-Hans, zh-Hant (24 `.lproj` markers; one pack is diff --git a/docs/localization-ru.md b/docs/localization-ru.md index ea0eff64df..f3a68f8540 100644 --- a/docs/localization-ru.md +++ b/docs/localization-ru.md @@ -63,12 +63,8 @@ translated to stay aligned with the other catalogs. ## Validation -- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) - enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires - a Russian row for each, like the Korean and Italian tests. -- `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (`crates/ui-egui/src/i18n/mod.rs`) - covers the rasterize prompts, hardware status lines, composed status templates and a sample of - filter labels. +- Every filter and adjustment parameter, symbolic choice and gallery name has a Russian row, as do + the rasterize prompts, hardware status lines and composed status templates. - `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed. - `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys. - The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index d92829f36e..9a84589589 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -28,8 +28,8 @@ language manually there. - `crates/ui-egui/src/i18n/zh-hans.tsv` contains the translations, independent of the lookup implementation. Its UTF-8 columns are `contextsourcetranslation`. - `crates/ui-egui/src/i18n/mod.rs` registers `zh-hans` with one plural form and - `complete_menus: true`. Shared tests enforce coverage of every menu string, - `tl!` literal, blend mode, brush section and generated preference label. + `complete_menus: true`. `cargo xtask i18n-coverage` lists any menu string, + `tl!` literal or other source key the catalog lacks; those show in English. - Shortcut templates retain `{key}` so the shell supplies the correct key for the current platform. Layer-count messages use one Chinese plural form. @@ -62,9 +62,8 @@ complete catalog coverage does not claim those strings are translated. Run `cargo test -p photocraft-ui-egui`, the touched-crate all-target clippy check, `cargo xtask layers` and `cargo xtask wasm`. The shared catalog tests validate -duplicate keys, placeholders, ellipses, menu coverage, `tl!` literals, brush -sections and blend modes. Chinese-specific tests cover locale selection, fallback, -plural messages, formatted labels and the painted brush section rows and heading. Render and inspect the menus, Preferences and representative +duplicate keys, placeholders, ellipses and plural forms. Chinese-specific tests +cover locale selection, fallback and the painted brush section rows. Render and inspect the menus, Preferences and representative dialogs with the existing offscreen `snapshot` example. Font delivery is separate from the translation data. The catalog adds no font @@ -73,7 +72,6 @@ label layout and platform-specific shortcuts visually on each target platform, and verify web glyph coverage separately. When English labels change, update their source keys and translations together, -preserving contexts and placeholders. The complete-catalog tests catch missing -keys, and the Simplified Chinese regression test checks dynamic shortcut labels, -layer counts and English fallback. Review changed source meanings against the -current English UI. +preserving contexts and placeholders. `cargo xtask i18n-coverage` (also the last +step of `cargo xtask ci`) lists the keys still missing. Review changed source +meanings against the current English UI. diff --git a/docs/localization.md b/docs/localization.md index 5a1355057c..30fe04725a 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -31,7 +31,9 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | हिन्दी | `hi` | one / other | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend -mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is +mode names, and generated preference labels. A string a catalog lacks shows in English: +`cargo xtask i18n-coverage` lists the missing strings per language, and `cargo xtask ci` prints +that list as its last step without failing. Vietnamese (`vi`) is selectable, resolves regional tags such as `vi-VN`, and covers all current stable localization keys. `complete_menus` remains false while generated/dynamic long-tail labels are still under review. This does not include every engine error or status message: those remain English, as do @@ -134,7 +136,7 @@ detection remain separate outstanding work. Switching catalogs does not supply m 3. Translate the meaning of the English source keys. Preserve `{name}` placeholders, escaped characters and the trailing `…` on dialog commands. Use `@id` to disambiguate a command label and `@plural` for count-sensitive text. Keep product names such as PhotoCraft. -4. Set `complete_menus` when the catalog covers all enforced keys. Add source and license +4. Set `complete_menus` once `cargo xtask i18n-coverage` lists nothing missing for the language. Add source and license attribution for translation assets, then run the catalog and shell tests. ```sh diff --git a/docs/scorecard.md b/docs/scorecard.md index c3d21c8a0d..09f8c61ab9 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -153,7 +153,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | -| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout | +| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | diff --git a/docs/ui-design.md b/docs/ui-design.md index 8ac01f8e01..24a30a16ed 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -198,7 +198,7 @@ documented in the header of `ja.tsv`). Command ids, menu paths used for logic, t the CLI and MCP always use the English ids and labels. Languages shipped (`complete_menus` marks a catalog that covers every menu string and `tl!` literal; -the tests enforce it): +`cargo xtask i18n-coverage` lists what each catalog lacks): - English (`en`), the source language. - Japanese (`ja`), complete. @@ -225,15 +225,16 @@ the tests enforce it): - To add a language: add `.tsv` and one row in `i18n::LANGUAGES` (code, native name, catalog, plural rule). The dropdown, locale matching and the catalog tests (well-formed, no duplicates, placeholders and ellipses agree, command ids exist) pick it up. Set `complete_menus` once every - menu string is translated; a test then enforces it. A language with more plural forms lists them + menu string is translated. A language with more plural forms lists them all in `@plural` entries (Russian: `one|few|many`); the row's plural rule picks the form. - Translations are clean-room: written from the meaning of the English text in ordinary vocabulary, never from another product's localisation resources. Localised so far: menus, the command palette, dialogs and panels (literals wrapped in `tl!("…")`; widgets such as `checkbox`, `slider_row`, `dropdown` and the buttons translate their labels -themselves). A test fails when a `tl!` literal, a menu string, a blend mode name, brush section name or a generated -preference label has no entry in a language marked `complete_menus`. Not translated: status-bar +themselves). A string with no entry in a catalog shows in English; `cargo xtask i18n-coverage` +lists the missing strings per language, and `cargo xtask ci` prints that list without failing. +Not translated: status-bar messages and errors (they stay English, also for agents), names that are user data (layers, styles, documents), strings assembled with `format!` that were not converted to `fmt`/`trn`. Not done yet: right-to-left layout inside panels and dialogs (the shell mirrors, `direction.rs`), @@ -245,7 +246,7 @@ Simplified Chinese. Its contextual `cameraRaw` entries distinguish tonal regions colour-band names and font-weight Light. The title uses a `{layer}` placeholder; user layer names and the universal RGB/Lab channel symbols stay unchanged. Collapse IDs use English source keys, so switching languages preserves open sections. Mixer tabs wrap within the -panel width. Catalog coverage and actual shell language-switch tests enforce these rules. +panel width. Shell language-switch tests check that the panel draws in the selected language. Native CJK font fallback follows the selected UI script and resets its cache when switching languages; font delivery on the web remains separate work. diff --git a/packaging/env.sh b/packaging/env.sh index 0007227058..5b4bfe0b4c 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -22,7 +22,7 @@ workspace_version() { } VERSION="${PHOTOCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +32,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${PHOTOCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${PHOTOCRAFT_BUILD_SHA:-}" ]]; then PHOTOCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export PHOTOCRAFT_BUILD_SHA @@ -41,14 +41,14 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" } @@ -57,9 +57,9 @@ copy_docs() { # are OFL-1.1: ship each font's licence as OFL-.txt. No-op without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" lic fam - [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0 for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - [ -f "$lic" ] || continue + [[ -f "$lic" ]] || continue fam="$(basename "$(dirname "$lic")")" cp "$lic" "$dest/OFL-$fam.txt" done diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index b03af6be55..71296da600 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -20,7 +20,7 @@ APP_ID=ai.storyteller.photocraft SKIP_BUILD=0 DRY_RUN=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;; @@ -36,7 +36,7 @@ case "$MACHINE" in arm64 | aarch64) ARCH=aarch64 ;; *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; esac -if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then +if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 fi @@ -46,7 +46,7 @@ echo "==> PhotoCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif) fi WORK="$CARGO_TARGET_DIR/freebsd-package" @@ -56,7 +56,7 @@ mkdir -p "$WORK" BIN="$CARGO_TARGET_DIR/release" OUT_DIR="$DIST" -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. BIN="$WORK/stub-bin" OUT_DIR="$WORK" @@ -81,13 +81,13 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$PHOTOCRAFT_BUILD_DATE/g" \ cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" copy_docs "$STAGE/share/doc/photocraft" for f in NOTICE ATTRIBUTION.md; do - if [ -f "$ROOT/$f" ]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/photocraft/"; fi + if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/photocraft/"; fi done for f in bin/photocraft bin/photocraft-cli "share/applications/$APP_ID.desktop" \ "share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \ share/doc/photocraft/LICENSE-MIT share/doc/photocraft/LICENSE-APACHE; do - if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi + if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi done # ---- .tar.gz ------------------------------------------------------------------------------------ @@ -96,7 +96,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" "$STAGE/bin/photocraft-cli" --version -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" fi diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh index 18f920560b..17e4923cf3 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -37,10 +37,10 @@ fail() { # 1. Launch works and passes arguments through. out="$("$APPDIR/AppRun" open file.png)" || fail "AppRun exited non-zero" -[ "$out" = "stub: open file.png" ] || fail "arguments not forwarded: $out" +[[ "$out" = "stub: open file.png" ]] || fail "arguments not forwarded: $out" DESKTOP="$XDG_DATA_HOME/applications/$APP_ID.desktop" -[ -f "$DESKTOP" ] || fail "desktop entry not installed" +[[ -f "$DESKTOP" ]] || fail "desktop entry not installed" # 2. Exec rewritten to the AppImage (quoted, spaces intact), TryExec dropped, # everything else preserved from the packaged entry. @@ -56,15 +56,15 @@ fi # 3. Icons landed under the name the .desktop entry references. for f in "16x16/apps/$APP_ID.png" "256x256/apps/$APP_ID.png" "scalable/apps/$APP_ID.svg"; do - [ -f "$XDG_DATA_HOME/icons/hicolor/$f" ] || fail "icon missing: $f" + [[ -f "$XDG_DATA_HOME/icons/hicolor/$f" ]] || fail "icon missing: $f" done # 4. Idempotent: a second launch must not rewrite anything (inode unchanged). before="$(stat -c %i "$DESKTOP")" out="$("$APPDIR/AppRun")" || fail "second launch exited non-zero" -[ "$out" = "stub: " ] || fail "second launch output: $out" +[[ "$out" = "stub: " ]] || fail "second launch output: $out" after="$(stat -c %i "$DESKTOP")" -[ "$before" = "$after" ] || fail "desktop entry rewritten although unchanged" +[[ "$before" = "$after" ]] || fail "desktop entry rewritten although unchanged" # 5. Moving the AppImage refreshes Exec. mv "$APPIMAGE" "$TMP/moved.AppImage" @@ -78,9 +78,9 @@ grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec not ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" "$APPDIR/AppRun" >/dev/null || fail "launch with external integrator desktop entry exited non-zero" -[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept alongside external integrator entry" -[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" -[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with external integrator" +[[ ! -e "$DESKTOP" ]] || fail "duplicate desktop entry kept alongside external integrator entry" +[[ -f "$ext_desktop" ]] || fail "external integrator desktop entry removed" +[[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ]] || fail "icons missing with external integrator" rm -f "$ext_desktop" # 7. AppImageLauncher exports DESKTOPINTEGRATION even after X-AppImage-Integrate=false @@ -88,53 +88,53 @@ rm -f "$ext_desktop" # writes its own entry and a later launch must not delete it (#5534). rm -rf "$XDG_DATA_HOME" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "DESKTOPINTEGRATION launch exited non-zero" -[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION suppressed the desktop entry (#5534)" +[[ -f "$DESKTOP" ]] || fail "DESKTOPINTEGRATION suppressed the desktop entry (#5534)" grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec missing under DESKTOPINTEGRATION" grep -Fxq "X-AppImage-Integrate=false" "$DESKTOP" || fail "X-AppImage-Integrate=false missing under DESKTOPINTEGRATION" -[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons not installed under DESKTOPINTEGRATION" +[[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ]] || fail "icons not installed under DESKTOPINTEGRATION" before="$(stat -c %i "$DESKTOP")" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "second DESKTOPINTEGRATION launch exited non-zero" -[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION removed the desktop entry on a later launch" -[ "$before" = "$(stat -c %i "$DESKTOP")" ] || fail "desktop entry rewritten under DESKTOPINTEGRATION although unchanged" +[[ -f "$DESKTOP" ]] || fail "DESKTOPINTEGRATION removed the desktop entry on a later launch" +[[ "$before" = "$(stat -c %i "$DESKTOP")" ]] || fail "desktop entry rewritten under DESKTOPINTEGRATION although unchanged" # A real integrator entry still wins, including when DESKTOPINTEGRATION is also set (#3623). ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "launch with integrator entry and DESKTOPINTEGRATION exited non-zero" -[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept when an integrator entry exists" -[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" -[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing when DESKTOPINTEGRATION and an integrator entry coexist" +[[ ! -e "$DESKTOP" ]] || fail "duplicate desktop entry kept when an integrator entry exists" +[[ -f "$ext_desktop" ]] || fail "external integrator desktop entry removed" +[[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ]] || fail "icons missing when DESKTOPINTEGRATION and an integrator entry coexist" rm -f "$ext_desktop" # 8. Opt-out installs nothing. rm -rf "$XDG_DATA_HOME" PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1 "$APPDIR/AppRun" >/dev/null || fail "opt-out launch exited non-zero" -[ ! -e "$XDG_DATA_HOME" ] || fail "opt-out still integrated" +[[ ! -e "$XDG_DATA_HOME" ]] || fail "opt-out still integrated" # 9. An unwritable data dir never blocks the launch. mkdir -p "$TMP/ro" && chmod a-w "$TMP/ro" out="$(XDG_DATA_HOME="$TMP/ro/data" "$APPDIR/AppRun" out.txt)" || fail "launch blocked by unwritable data dir" -[ "$out" = "stub: out.txt" ] || fail "stub not run against unwritable data dir: $out" +[[ "$out" = "stub: out.txt" ]] || fail "stub not run against unwritable data dir: $out" chmod u+w "$TMP/ro" # 10. No $APPIMAGE (extracted AppDir): launch only, nothing installed. rm -rf "$XDG_DATA_HOME" env -u APPIMAGE "$APPDIR/AppRun" >/dev/null || fail "extracted-AppDir launch exited non-zero" -[ ! -e "$XDG_DATA_HOME" ] || fail "integrated without $APPIMAGE" +[[ ! -e "$XDG_DATA_HOME" ]] || fail "integrated without $APPIMAGE" # 11. A path that would need Exec escaping (here a `$`): launch only, nothing installed. rm -rf "$XDG_DATA_HOME" odd="$TMP/odd\$(id).AppImage" : >"$odd" out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exited non-zero" -[ "$out" = "stub: x" ] || fail "odd path: $out" -[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping" +[[ "$out" = "stub: x" ]] || fail "odd path: $out" +[[ ! -e "$XDG_DATA_HOME" ]] || fail "integrated an AppImage path that needs escaping" # 12. AM / AppMan (#6392): a photocraft-AM.desktop launcher in the user's applications # dir owns the menu entry. AppRun removes its own entry left from an earlier run, # writes no second one and still installs the icons. rm -rf "$XDG_DATA_HOME" "$APPDIR/AppRun" >/dev/null || fail "launch before AM install exited non-zero" -[ -f "$DESKTOP" ] || fail "no desktop entry before AM install" +[[ -f "$DESKTOP" ]] || fail "no desktop entry before AM install" am_desktop="$XDG_DATA_HOME/applications/photocraft-AM.desktop" printf '[Desktop Entry] Type=Application @@ -143,13 +143,13 @@ Exec=photocraft %%F Icon=%s ' "$APP_ID" >"$am_desktop" "$APPDIR/AppRun" >/dev/null || fail "launch with AM launcher exited non-zero" -[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept alongside AM launcher (#6392)" -[ -f "$am_desktop" ] || fail "AM launcher was removed" -[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with AM launcher" +[[ ! -e "$DESKTOP" ]] || fail "duplicate desktop entry kept alongside AM launcher (#6392)" +[[ -f "$am_desktop" ]] || fail "AM launcher was removed" +[[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ]] || fail "icons missing with AM launcher" # 13. Once AM removes its launcher, AppRun writes its own entry again. rm -f "$am_desktop" "$APPDIR/AppRun" >/dev/null || fail "launch after AM removal exited non-zero" -[ -f "$DESKTOP" ] || fail "no desktop entry after the AM launcher was removed" +[[ -f "$DESKTOP" ]] || fail "no desktop entry after the AM launcher was removed" echo "AppRun desktop integration ok" diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 597866523a..bbbedacae1 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -21,7 +21,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 TARBALL="" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; @@ -38,7 +38,7 @@ case "$ARCH" in esac BASENAME="photocraft-$VERSION-linux-$ARCH" TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" -[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/photocraft" ] || { echo "error: $TARBALL has no bin/photocraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/photocraft" ]] || { echo "error: $TARBALL has no bin/photocraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=photocraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 615bef25bf..e245d254ca 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -27,7 +27,7 @@ APP_ID=ai.storyteller.photocraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -48,8 +48,8 @@ BASENAME="photocraft-$VERSION-linux-$ARCH" echo "==> PhotoCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif) @@ -136,7 +136,7 @@ if has appimage; then esac mkdir -p "$CARGO_TARGET_DIR" TOOL="${APPIMAGETOOL:-$CARGO_TARGET_DIR/appimagetool-$TOOL_VERSION-$ARCH.AppImage}" - if [ -n "${APPIMAGETOOL:-}" ]; then + if [[ -n "${APPIMAGETOOL:-}" ]]; then verify_appimagetool_override "$TOOL" "$TOOL_SHA256" "$TOOL_VERSION" "$ARCH" else download_verified \ @@ -167,16 +167,16 @@ if has appimage; then (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream \ --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/photocraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/photocraft-cli" --version fi echo "==> done" diff --git a/packaging/linux/verified-download-test.sh b/packaging/linux/verified-download-test.sh index 540cbd54bc..cfc9382f32 100644 --- a/packaging/linux/verified-download-test.sh +++ b/packaging/linux/verified-download-test.sh @@ -22,7 +22,7 @@ expected="$(sha256 "$source_asset")" cp "$source_asset" "$cache" /bin/chmod 644 "$cache" download_verified "file://$TMP/unavailable" "$cache" "$expected" true || fail "offline cache was not reused" -[ -x "$cache" ] || fail "valid preseed did not become executable" +[[ -x "$cache" ]] || fail "valid preseed did not become executable" # If a valid but non-executable cache cannot be chmod'd, fail here rather than at invocation. /bin/chmod 644 "$cache" @@ -44,27 +44,27 @@ cp "$source_asset" "$runtime" chmod() { fail "chmod called for a readable runtime cache"; } download_verified "file://$TMP/unavailable" "$runtime" "$expected" || fail "readable runtime was not reused" unset -f chmod -[ ! -x "$runtime" ] || fail "runtime was needlessly made executable" +[[ ! -x "$runtime" ]] || fail "runtime was needlessly made executable" # A fresh runtime download is verified, readable data rather than an executable program. fresh_runtime="$TMP/fresh-runtime" download_verified "file://$source_asset" "$fresh_runtime" "$expected" || fail "fresh runtime download failed" cmp -s "$source_asset" "$fresh_runtime" || fail "fresh runtime differs from verified asset" -[ -r "$fresh_runtime" ] || fail "fresh runtime is not readable" -[ ! -x "$fresh_runtime" ] || fail "fresh runtime was made executable" +[[ -r "$fresh_runtime" ]] || fail "fresh runtime is not readable" +[[ ! -x "$fresh_runtime" ]] || fail "fresh runtime was made executable" mode="$(stat -c %a "$fresh_runtime" 2>/dev/null || stat -f %Lp "$fresh_runtime")" -[ "$mode" = 644 ] || fail "fresh runtime mode is $mode, expected 644" +[[ "$mode" = 644 ]] || fail "fresh runtime mode is $mode, expected 644" # A corrupt cache is replaced only by bytes with the pinned digest. printf 'corrupt' >"$cache" download_verified "file://$source_asset" "$cache" "$expected" true || fail "corrupt cache was not replaced" cmp -s "$source_asset" "$cache" || fail "replacement differs from verified asset" -[ -x "$cache" ] || fail "replacement is not executable" +[[ -x "$cache" ]] || fail "replacement is not executable" wrong_digest="$(printf '%064d' 0)" bad="$TMP/bad.AppImage" if download_verified "file://$source_asset" "$bad" "$wrong_digest" >/dev/null 2>&1; then fail "wrong digest accepted"; fi -[ ! -e "$bad" ] || fail "wrong-digest download left a cache entry" +[[ ! -e "$bad" ]] || fail "wrong-digest download left a cache entry" verify_appimagetool_override "$cache" "$expected" 1.9.1 x86_64 || fail "valid override rejected" /bin/chmod 644 "$cache" diff --git a/packaging/linux/verified-download.sh b/packaging/linux/verified-download.sh index 75c816a61a..ce6eb1cdfc 100644 --- a/packaging/linux/verified-download.sh +++ b/packaging/linux/verified-download.sh @@ -4,8 +4,8 @@ download_verified() { local url="$1" dest="$2" expected="$3" executable="${4:-false}" tmp - if [ -f "$dest" ] && [ "$(sha256 "$dest")" = "$expected" ]; then - if [ "$executable" = true ] && [ ! -x "$dest" ] && ! chmod 755 "$dest"; then + if [[ -f "$dest" ]] && [[ "$(sha256 "$dest")" = "$expected" ]]; then + if [[ "$executable" = true ]] && [[ ! -x "$dest" ]] && ! chmod 755 "$dest"; then echo "error: verified asset is not executable and cannot be chmod'd: $dest" >&2 return 1 fi @@ -13,12 +13,12 @@ download_verified() { fi tmp="$(mktemp "$dest.XXXXXX")" if ! curl -fsSL -o "$tmp" "$url"; then rm -f "$tmp"; return 1; fi - if [ "$(sha256 "$tmp")" != "$expected" ]; then + if [[ "$(sha256 "$tmp")" != "$expected" ]]; then echo "error: SHA-256 mismatch for $url" >&2 rm -f "$tmp" return 1 fi - if [ "$executable" = true ]; then + if [[ "$executable" = true ]]; then if ! chmod 755 "$tmp"; then rm -f "$tmp"; return 1; fi else if ! chmod 644 "$tmp"; then rm -f "$tmp"; return 1; fi @@ -28,11 +28,11 @@ download_verified() { verify_appimagetool_override() { local tool="$1" expected="$2" version="$3" arch="$4" - if [ ! -f "$tool" ] || [ "$(sha256 "$tool")" != "$expected" ]; then + if [[ ! -f "$tool" ]] || [[ "$(sha256 "$tool")" != "$expected" ]]; then echo "error: APPIMAGETOOL must be the verified $version $arch release asset" >&2 return 1 fi - if [ ! -x "$tool" ]; then + if [[ ! -x "$tool" ]]; then echo "error: APPIMAGETOOL must be executable (chmod +x $tool)" >&2 return 1 fi diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 236f443224..d67e7a6893 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 194b379293..1b0974a340 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/photocraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/photocraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> PhotoCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif "${args[@]}") @@ -74,9 +74,9 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } @@ -88,8 +88,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -104,7 +104,7 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$WORK/bin/photocraft" "$APP/Contents/MacOS/PhotoCraft" cp "$ROOT/assets/app-icon/photocraft.icns" "$APP/Contents/Resources/PhotoCraft.icns" # Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR). -if [ -n "${CRAFT_FONTS_DIR:-}" ]; then +if [[ -n "${CRAFT_FONTS_DIR:-}" ]]; then mkdir -p "$APP/Contents/Resources/Licenses" copy_font_licences "$APP/Contents/Resources/Licenses" fi @@ -120,7 +120,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/PhotoCraft-notarize.zip" notarize "$WORK/PhotoCraft-notarize.zip" xcrun stapler staple "$APP" @@ -147,7 +147,7 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" @@ -168,7 +168,7 @@ rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # notarytool accepts a zip of the bare binary. A bare Mach-O can't carry a stapled ticket, so # Gatekeeper looks the notarization up online when a quarantined copy first runs. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/photocraft-cli" --version echo "==> done (check the artifacts as shipped with packaging/macos/verify.sh --arch $ARCH)" diff --git a/packaging/macos/verify.sh b/packaging/macos/verify.sh index 496aad1174..7521d44e03 100755 --- a/packaging/macos/verify.sh +++ b/packaging/macos/verify.sh @@ -23,7 +23,7 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" ARCH=universal -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; -h | --help) sed -n '2,20p' "$0"; exit 0 ;; @@ -41,13 +41,13 @@ DMG="$DIST/photocraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/photocraft-cli-$VERSION-macos-$ARCH.zip" IDENTITY="${MACOS_SIGN_IDENTITY:--}" STRICT=0 -if [ "$IDENTITY" != "-" ] && [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +if [[ "$IDENTITY" != "-" ]] && [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then STRICT=1 fi FAILED=0 fail() { - if [ "$STRICT" = 1 ]; then + if [[ "$STRICT" = 1 ]]; then echo "error: $*" >&2 FAILED=1 else @@ -58,20 +58,20 @@ fail() { # spctl's online ticket lookup can lag a few seconds behind notarytool's "Accepted". assess() { local out attempt tries=1 - [ "$STRICT" = 1 ] && tries=5 + [[ "$STRICT" = 1 ]] && tries=5 for attempt in $(seq "$tries"); do if out="$(spctl "$@" 2>&1)"; then printf '%s\n' "$out" return 0 fi - [ "$attempt" = "$tries" ] || sleep 10 + [[ "$attempt" = "$tries" ]] || sleep 10 done printf '%s\n' "$out" return 1 } for f in "$DMG" "$CLI_ZIP"; do - if [ ! -f "$f" ]; then + if [[ ! -f "$f" ]]; then echo "error: missing $f" >&2 exit 1 fi @@ -84,7 +84,7 @@ trap 'rm -rf "$WORK"' EXIT echo "==> $(basename "$CLI_ZIP")" ditto -x -k "$CLI_ZIP" "$WORK/cli" CLI="$WORK/cli/photocraft-cli-$VERSION-macos-$ARCH/photocraft-cli" -if [ ! -x "$CLI" ]; then +if [[ ! -x "$CLI" ]]; then echo "error: $(basename "$CLI_ZIP") has no executable photocraft-cli-$VERSION-macos-$ARCH/photocraft-cli" >&2 exit 1 fi @@ -108,7 +108,7 @@ printf '%s\n' "$details" | grep -q '^Authority=Developer ID Application:' \ || fail "photocraft-cli is not signed with a Developer ID Application identity" printf '%s\n' "$details" | grep -q '^Timestamp=' \ || fail "photocraft-cli signature has no secure timestamp" -if [ -n "${APPLE_TEAM_ID:-}" ]; then +if [[ -n "${APPLE_TEAM_ID:-}" ]]; then printf '%s\n' "$details" | grep -qx "TeamIdentifier=$APPLE_TEAM_ID" \ || fail "photocraft-cli is signed by a different team" fi @@ -125,24 +125,24 @@ fi # ---- DMG --------------------------------------------------------------------------------------- echo "==> $(basename "$DMG")" codesign --verify --strict --verbose=2 "$DMG" -if [ "$STRICT" = 1 ]; then +if [[ "$STRICT" = 1 ]]; then xcrun stapler validate "$DMG" || fail "no notarization ticket stapled to the DMG" assess --assess --type open --context context:primary-signature -vv "$DMG" \ || fail "Gatekeeper rejects the DMG" fi -if [ "$FAILED" != 0 ]; then +if [[ "$FAILED" != 0 ]]; then echo "error: macOS artifacts failed verification" >&2 exit 1 fi -if [ "$STRICT" = 1 ]; then +if [[ "$STRICT" = 1 ]]; then result="signed (Developer ID, hardened runtime), notarized; DMG stapled" else result="signature intact; Developer ID and notarization not required (no signing secrets)" fi echo "==> verified: $result" -if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { echo "- macOS \`$(basename "$DMG")\`, \`$(basename "$CLI_ZIP")\`: $result" } >>"$GITHUB_STEP_SUMMARY" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 58b9bccd07..ccb06aded3 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -15,7 +15,7 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; } (cd "$ROOT/apps/photocraft-web" && trunk build --release) (cd "$ROOT/apps/photocraft-web-boot" && trunk build --release) @@ -24,7 +24,7 @@ fi APP="$ROOT/dist/web" # the app: index.html, wasm-bindgen glue, one .wasm BOOT="$ROOT/dist/web-boot" # the page that ships, and the module that starts the app from parts for site in "$APP" "$BOOT"; do - [ -f "$site/index.html" ] || { echo "error: $site/index.html missing; run without --skip-build" >&2; exit 1; } + [[ -f "$site/index.html" ]] || { echo "error: $site/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml). if grep -Eq '(src|href)="/[^/]' "$site/index.html"; then echo "error: $site/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 @@ -35,23 +35,23 @@ done # Size limit (issues #198, #5302): Cloudflare Pages/Workers reject any single file over 25 MiB, # and other hosts and CDNs have similar caps. No file in the site may come near that; the app's # wasm, the one file that does, ships as parts of PART_BYTES. Override only to investigate. -MAX_FILE_BYTES="${PHOTOCRAFT_WEB_MAX_FILE_BYTES:-25165824}" # 24 MiB -PART_BYTES="${PHOTOCRAFT_WEB_PART_BYTES:-20971520}" # 20 MiB -if [ "$PART_BYTES" -le 0 ] || [ "$PART_BYTES" -gt "$MAX_FILE_BYTES" ]; then +MAX_FILE_BYTES=$((10#${PHOTOCRAFT_WEB_MAX_FILE_BYTES:-25165824})) # 24 MiB +PART_BYTES=$((10#${PHOTOCRAFT_WEB_PART_BYTES:-20971520})) # 20 MiB +if [[ "$PART_BYTES" -le 0 ]] || [[ "$PART_BYTES" -gt "$MAX_FILE_BYTES" ]]; then echo "error: a part of $PART_BYTES bytes doesn't fit the $MAX_FILE_BYTES-byte file limit" >&2 exit 1 fi WASM="" for wasm in "$APP"/*_bg.wasm; do - [ -f "$wasm" ] || continue - [ -z "$WASM" ] || { echo "error: more than one .wasm in $APP; remove it and run without --skip-build" >&2; exit 1; } + [[ -f "$wasm" ]] || continue + [[ -z "$WASM" ]] || { echo "error: more than one .wasm in $APP; remove it and run without --skip-build" >&2; exit 1; } WASM="$wasm" done -[ -n "$WASM" ] || { echo "error: no .wasm in $APP" >&2; exit 1; } +[[ -n "$WASM" ]] || { echo "error: no .wasm in $APP" >&2; exit 1; } STEM="$(basename "$WASM" _bg.wasm)" # photocraft-web- GLUE="$APP/$STEM.js" -[ -f "$GLUE" ] || { echo "error: $GLUE missing" >&2; exit 1; } +[[ -f "$GLUE" ]] || { echo "error: $GLUE missing" >&2; exit 1; } NAME="photocraft-web-$VERSION" WORK="$CARGO_TARGET_DIR/web-package" @@ -80,9 +80,9 @@ copy_docs "$WORK/$NAME" # Optional served fonts (README.md › Fonts): PHOTOCRAFT_WEB_FONTS_DIR= copies the manifest, every font and licence file # it lists into the site, at the same paths. The wasm is unchanged; the app fetches fonts on demand. -if [ -n "${PHOTOCRAFT_WEB_FONTS_DIR:-}" ]; then +if [[ -n "${PHOTOCRAFT_WEB_FONTS_DIR:-}" ]]; then FONTS_MANIFEST="$PHOTOCRAFT_WEB_FONTS_DIR/fonts/manifest.txt" - [ -f "$FONTS_MANIFEST" ] || { echo "error: $FONTS_MANIFEST missing (PHOTOCRAFT_WEB_FONTS_DIR)" >&2; exit 1; } + [[ -f "$FONTS_MANIFEST" ]] || { echo "error: $FONTS_MANIFEST missing (PHOTOCRAFT_WEB_FONTS_DIR)" >&2; exit 1; } mkdir -p "$WORK/$NAME/fonts" cp "$FONTS_MANIFEST" "$WORK/$NAME/fonts/manifest.txt" # Field 3 is the font file, field 6 (if any) its licence file; both relative to the folder. @@ -98,8 +98,8 @@ fi largest=0 while IFS= read -r file; do bytes=$(size "$file") - [ "$bytes" -le "$largest" ] || largest=$bytes - if [ "$bytes" -gt "$MAX_FILE_BYTES" ]; then + [[ "$bytes" -le "$largest" ]] || largest=$bytes + if [[ "$bytes" -gt "$MAX_FILE_BYTES" ]]; then echo "error: ${file#"$WORK/$NAME/"} is $bytes bytes, over the $MAX_FILE_BYTES-byte limit (Cloudflare's per-file cap is 25 MiB)" >&2 exit 1 fi diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 6a1ac772f7..2ec39ab4d9 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -115,7 +115,7 @@ id = "TOOL-214-1" target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs" status = "partial" issue = 214 -note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout" +note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet" [[item]] id = "TOOL-214-2" diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index ee337c0b9b..cd8fbbce63 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -72,9 +72,33 @@ fn format_report(languages: &[Language], english_keys: &BTreeSet, translati let coverage = 100.0 * translated as f64 / english_keys.len() as f64; report.push_str(&format!("{:<12} {:>5}/{:<6} {:>8.2}%\n", language.code, translated, english_keys.len(), coverage)); } + report.push_str("\nMissing translations (shown in English until translated)\n"); + for language in languages.iter().filter(|language| language.code != "en") { + let empty = BTreeSet::new(); + let translated = translations.get(&language.code).unwrap_or(&empty); + let missing: Vec<&Key> = english_keys.difference(translated).collect(); + if missing.is_empty() { + report.push_str(&format!("{}: none missing\n", language.code)); + continue; + } + report.push_str(&format!("{}: {} missing\n", language.code, missing.len())); + for key in missing { + let source = escape_line(&key.source); + if key.context.is_empty() { + report.push_str(&format!(" {source}\n")); + } else { + report.push_str(&format!(" [{}] {source}\n", escape_line(&key.context))); + } + } + } report } +/// Keeps one key per log line: a source string with a newline or tab prints them escaped. +fn escape_line(text: &str) -> String { + text.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t") +} + fn registered_languages(source: &str) -> Result, String> { let registry = source .split("pub static LANGUAGES") @@ -494,15 +518,28 @@ mod tests { let languages = registered_languages(registry); assert!(languages.is_ok()); let languages = languages.unwrap_or_default(); - let english_keys = BTreeSet::from([Key { context: String::new(), source: "Open".into() }, Key { context: String::new(), source: "Save".into() }]); - let translations = BTreeMap::from([("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }]))]); + let english_keys = BTreeSet::from([ + Key { context: String::new(), source: "Open".into() }, + Key { context: String::new(), source: "Save".into() }, + Key { context: "@plural".into(), source: "{n} layer|{n} layers".into() }, + ]); + let translations = BTreeMap::from([ + ("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }])), + ("zh".to_string(), english_keys.clone()), + ]); assert_eq!( format_report(&languages, &english_keys, &translations), "UI translation coverage (translated source keys / source keys)\n\ language translated coverage\n\ - en 2/2 100.00%\n\ - fr 1/2 50.00%\n\ - zh 0/2 0.00%\n" + en 3/3 100.00%\n\ + fr 1/3 33.33%\n\ + zh 3/3 100.00%\n\ + \n\ + Missing translations (shown in English until translated)\n\ + fr: 2 missing\n\ + \x20 Save\n\ + \x20 [@plural] {n} layer|{n} layers\n\ + zh: none missing\n" ); } @@ -563,34 +600,6 @@ mod tests { } } - #[test] - fn source_key_set_is_covered_by_complete_catalogs() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new(".")); - let keys = source_keys(root).unwrap_or_default(); - for (code, path) in [ - ("ja", "ja.tsv"), - ("zh-hant", "zh-hant.tsv"), - ("es", "es.tsv"), - ("ru", "ru.tsv"), - ("cs", "cs.tsv"), - ("de", "de.tsv"), - ("pt-br", "pt-br.tsv"), - ("it", "it.tsv"), - ("el", "el.tsv"), - ("uk", "uk.tsv"), - ("nl", "nl.tsv"), - ("hu", "hu.tsv"), - ("tr", "tr.tsv"), - ("vi", "vi.tsv"), - ("sr", "sr.tsv"), - ] { - let catalog_path = root.join("crates/ui-egui/src/i18n").join(path); - let text = fs::read_to_string(&catalog_path).unwrap_or_default(); - let translations = parse_catalog(&catalog_path, &text).unwrap_or_default(); - let missing: Vec<_> = keys.difference(&translations).collect(); - assert!(missing.is_empty(), "{code}: missing source keys: {missing:#?}"); - } - } #[test] fn plural_forms_follow_count_expressions_and_dynamic_forms_are_not_literal_keys() { let sources = vec![( diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7136f90ecf..545bce9d23 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -16,15 +16,19 @@ mod stats; mod version; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::{Command, ExitCode, Stdio}; const USAGE: &str = "\ usage: cargo xtask commands: layers enforce the crate dependency layering (docs/architecture.md §3) - wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates - ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) + wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus + one per optional web feature) + test workspace tests (cargo nextest when installed, else cargo test), then doctests + ci the CI gate: fmt --check, clippy -D warnings, layers, scorecard --check, tests + (nextest --profile ci), panic_hunt, doctests and wasm, with --locked --features heif + (stops at first failure), then the i18n-coverage report corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, @@ -36,7 +40,8 @@ commands: --local takes corpus/photoshop and corpus/gimp from the photocraft-corpus authoring clone stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) parity Photoshop menu parity; rewrites docs/parity-checklist.md - i18n-coverage report stable UI translation coverage for registered languages + i18n-coverage report UI translation coverage and the missing strings per registered language + (the last `ci` step; it never fails) perf [--quick] [--update-baseline] [--threshold PCT] [--bench NAME]... [--skip-build] [--reuse] run the release benches, merge them by scenario id into target/perf/results.json, check perf/budgets.toml and perf/baseline.json (non-zero on a broken budget or regression) @@ -54,6 +59,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), + Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), Some("corpus") => corpus::cmd(&rest), Some("test-corpus") => corpus::test_cmd(&rest), @@ -157,15 +163,23 @@ fn wasm_set() -> Result, String> { /// (package, feature). const WASM_FEATURES: &[(&str, &str)] = &[("photocraft-codecs", "heif")]; +/// One `cargo check` for the whole set: each crate is checked once, and `--keep-going` still +/// reports every failing crate. Features unify across the set, so each crate is checked with the +/// union of the features the set enables. The optional features get a call each, so the crates +/// are also checked without them. fn cmd_wasm() -> Result<(), String> { let set = wasm_set()?; - let mut results = Vec::new(); - for pkg in &set { - let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); - let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok(); - results.push((pkg.clone(), ok)); + if set.is_empty() { + return Err("no wasm-safe crates found in the workspace metadata".into()); } + let mut results = Vec::new(); + let mut c = cargo(); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); + for pkg in &set { + c.args(["-p", pkg]); + } + let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))).is_ok(); + results.push((format!("{} crates", set.len()), ok)); for (pkg, feature) in WASM_FEATURES { let mut c = cargo(); c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg, "--features", feature]); @@ -177,7 +191,61 @@ fn cmd_wasm() -> Result<(), String> { println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" }); } let failed = results.iter().filter(|r| !r.1).count(); - if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } + if failed == 0 { Ok(()) } else { Err(format!("{failed} wasm check(s) failed")) } +} + +/// Test targets for the test build. Examples are left out so they aren't linked; clippy +/// `--all-targets` still compiles them. +const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"]; + +fn nextest_available() -> bool { + cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) +} + +fn cmd_test() -> Result<(), String> { + let mut c = cargo(); + let tool = if nextest_available() { + c.args(["nextest", "run"]); + "nextest run" + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test"); + "test" + }; + c.args(TEST_TARGETS); + run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) +} + +/// What the CI gate adds to every build: `--locked`, and the optional photocraft-heif crate that +/// official builds open HEIC photos with. +const GATE_ARGS: [&str; 4] = ["--workspace", "--locked", "--features", "heif"]; + +/// The gate's tests. nextest's `ci` profile stops at the first failure and terminates hung tests +/// (.config/nextest.toml). `panic_hunt` runs only the engine's `#[ignore]`d panic hunt (AGENTS.md +/// Rule 9) with the same packages, features and targets as the tests, so nothing rebuilds. +fn cmd_gate_tests(panic_hunt: bool) -> Result<(), String> { + let mut c = cargo(); + if nextest_available() { + c.args(["nextest", "run", "--profile", "ci"]).args(GATE_ARGS).args(&TEST_TARGETS[1..]); + if panic_hunt { + c.args(["--run-ignored", "only", "-E", "binary_id(photocraft-engine::panic_hunt)"]); + } + } else if panic_hunt { + // `-- --ignored` across the workspace would run every ignored test, not only the hunt. + c.args(["test", "-p", "photocraft-engine", "--locked", "--test", "panic_hunt", "--", "--ignored"]); + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test").args(GATE_ARGS).args(&TEST_TARGETS[1..]); + } + let what = format!("cargo {}", c.get_args().map(|a| a.to_string_lossy()).collect::>().join(" ")); + run(c, &what) +} + +/// nextest does not run doctests. +fn cmd_doctest() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--doc"]); + run(c, "cargo test --workspace --doc") } fn cmd_parity() -> Result<(), String> { @@ -201,20 +269,33 @@ fn cmd_ci() -> Result<(), String> { "clippy", Box::new(|| { let mut c = cargo(); - c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]); - run(c, "cargo clippy --workspace --all-targets -- -D warnings") - }), - ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") + c.arg("clippy").args(GATE_ARGS).args(["--all-targets", "--keep-going", "--", "-D", "warnings"]); + run(c, "cargo clippy --workspace --locked --features heif --all-targets --keep-going -- -D warnings") }), ), ("layers", Box::new(cmd_layers)), + ("scorecard", Box::new(|| scorecard::run(&root(), &["--check"]))), + ("test", Box::new(|| cmd_gate_tests(false))), + ("panic_hunt", Box::new(|| cmd_gate_tests(true))), + ( + "doctest", + Box::new(|| { + let mut c = cargo(); + c.arg("test").args(GATE_ARGS).arg("--doc"); + run(c, "cargo test --workspace --locked --features heif --doc") + }), + ), ("wasm", Box::new(cmd_wasm)), + ( + "i18n", + Box::new(|| { + // Lists missing translations for the log; a missing string shows in English, so it never fails CI. + if let Err(e) = i18n_coverage::run(&root()) { + eprintln!("warning: i18n coverage report failed: {e}"); + } + Ok(()) + }), + ), ]; let mut done = Vec::new(); for (name, f) in &steps {