Merge upstream/main into fix/3290-filter-mask-paint

This commit is contained in:
vangel76 committed 2026-10-24 14:24:45 +02:00
commit 053e1e0d03
433 files changed
+29494 -4706

No files matched your search

+150 -185
View File
@@ -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 >/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 >/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 `<cache-version>-rust-<hash of Cargo.toml>-gate`.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: test
prefix-key: v1-rust
shared-key: ${{ hashFiles('Cargo.toml') }}-gate
save-if: false
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
- uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
with:
tool: cargo-nextest@0.9.146
tool: cargo-nextest@0.9.149
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: matrix.variant == 'without heif'
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
# The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese
# font tests that skip without it run. Keep the test job's exact `--workspace --features heif
# --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds.
# Filter execution to the three crates that read the optional font input (#6127).
- name: Check out craft-fonts
if: matrix.variant == 'with craft-fonts'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Test with craft-fonts
if: matrix.variant == 'with craft-fonts'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: >-
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
timeout-minutes: 30
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
@@ -306,7 +281,9 @@ jobs:
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
@@ -314,7 +291,7 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Restore test corpora
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: corpus/
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
@@ -323,23 +300,6 @@ jobs:
- name: Corpus tests
run: cargo xtask test-corpus
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Check wasm-safe crates
run: cargo xtask wasm
web:
timeout-minutes: 60
name: web build and wasm size gate
@@ -351,7 +311,9 @@ jobs:
# Match release.yml so PR results reflect what would actually ship.
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
@@ -409,6 +371,7 @@ jobs:
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { crate: psd, name: asl }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
@@ -419,7 +382,9 @@ jobs:
- { crate: affinity, name: import_preview, args: "--features io" }
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
@@ -433,7 +398,7 @@ jobs:
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
+2
View File
@@ -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:
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
+2
View File
@@ -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
+2
View File
@@ -88,6 +88,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
| `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
Generated
+88 -35
View File
@@ -4638,7 +4638,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"arboard",
"async-io",
@@ -4673,6 +4673,7 @@ dependencies = [
"ron",
"serde_json",
"tempfile",
"ureq",
"winit",
"winresource",
"winsafe",
@@ -4684,7 +4685,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4694,11 +4695,12 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"photocraft-testkit",
"rayon",
"rustfft",
"serde",
@@ -4707,7 +4709,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4730,7 +4732,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4745,7 +4747,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"moxcms",
"rayon",
@@ -4753,7 +4755,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"exr",
"flate2",
@@ -4774,7 +4776,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -4782,9 +4784,10 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"criterion",
"photocraft-cms",
"photocraft-color",
"photocraft-doc",
"photocraft-geom",
@@ -4797,7 +4800,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4808,7 +4811,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"blake3",
"chrono",
@@ -4832,11 +4835,13 @@ dependencies = [
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
"photocraft-testkit",
"photocraft-text",
"photocraft-vector",
"rayon",
"roxmltree 0.20.0",
"roxmltree 0.21.1",
"same-file",
"semver",
"serde",
"serde_json",
"tempfile",
@@ -4847,7 +4852,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"blake3",
"crc32fast",
@@ -4870,14 +4875,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"half",
"log",
@@ -4895,14 +4900,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"blake3",
"craft-docpath",
@@ -4921,29 +4926,30 @@ dependencies = [
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
"photocraft-testkit",
"photocraft-text",
"photocraft-vector",
"proptest",
"resvg",
"roxmltree 0.20.0",
"roxmltree 0.21.1",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ipp"
version = "0.12.0"
version = "0.15.1"
[[package]]
name = "photocraft-ops"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4954,12 +4960,13 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -4969,7 +4976,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4980,7 +4987,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4991,7 +4998,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"flate2",
"image",
@@ -5006,7 +5013,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -5018,7 +5025,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"memory-stats",
"serde_json",
@@ -5026,7 +5033,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"flate2",
"parley",
@@ -5041,7 +5048,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5084,7 +5091,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5095,7 +5102,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"eframe",
"egui",
@@ -5117,7 +5124,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"flate2",
"js-sys",
@@ -6053,12 +6060,14 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -7454,6 +7463,35 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -7493,6 +7531,12 @@ dependencies = [
"xmlwriter",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -7932,6 +7976,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -8713,7 +8766,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"chrono",
"serde",
+7 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.15.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -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
+54 -5
View File
@@ -32,9 +32,10 @@ USAGE:
only Gray and CMYK shrink) or progressive.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
applies to the preceding --cmd. Prints each command's JSON result. An --out that is the
<file> is refused, as the result would replace the original; --in-place allows it.
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
@@ -51,15 +52,20 @@ USAGE:
photocraft-cli mcp [--bridge <127.0.0.1:port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control <port>`).
Headless: each root grants that direction of file access; an omitted root denies it.
Prefer absolute root paths in MCP client configurations.
Bridge: set roots on the desktop app; CLI root flags are rejected. Explicit token flags
require --bridge; credentials can also use PHOTOCRAFT_CONTROL_TOKEN or PHOTOCRAFT_CONTROL_TOKEN_FILE.
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods
(docs/control-protocol.md#headless-server).
Explicit token flags require --port. Stdio needs no control token.
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
an error.
an error. mcp and serve take no positional arguments; their options cannot be repeated or empty.
";
struct Args {
@@ -99,7 +105,7 @@ const SUBCOMMANDS: &[Subcommand] = &[
"--tiff-compression",
"--jpeg-format",
],
bare: &["--tiff-layers", "--dds-mipmaps"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
@@ -154,7 +160,12 @@ fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
}
a.flags.push((k.to_owned(), Some(v.to_owned())));
} else if sub.values.contains(&s.as_str()) {
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
// A flag where the value should be is a missing value, not the value; help still wins.
let v = args.get(i + 1);
if v.is_some_and(|v| v == "--help" || v == "-h") {
return Ok(None);
}
let v = v.filter(|v| !v.starts_with("--")).ok_or_else(|| format!("{s} needs a value"))?;
a.flags.push((s.clone(), Some(v.clone())));
i += 1;
} else if sub.bare.contains(&s.as_str()) {
@@ -166,9 +177,43 @@ fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
}
i += 1;
}
validate_server_args(sub.name, &a)?;
Ok(Some(a))
}
/// Refuse launch options that would otherwise be silently ignored. In particular, bridge
/// roots belong to the desktop process: accepting them here promises authority we cannot set.
fn validate_server_args(sub: &str, a: &Args) -> R {
if !matches!(sub, "mcp" | "serve") {
return Ok(());
}
if !a.positional.is_empty() {
return Err(format!("{sub} does not take positional arguments"));
}
let mut seen = std::collections::HashSet::new();
for (flag, value) in &a.flags {
if !seen.insert(flag) {
return Err(format!("{flag} may only be specified once"));
}
if value.as_deref().is_none_or(str::is_empty) {
return Err(format!("{flag} needs a non-empty value"));
}
}
if a.has("--control-token") && a.has("--control-token-file") {
return Err("use either --control-token or --control-token-file, not both".into());
}
let transport = if sub == "mcp" { "--bridge" } else { "--port" };
for flag in ["--control-token", "--control-token-file"] {
if a.has(flag) && !a.has(transport) {
return Err(format!("{flag} requires {transport}"));
}
}
if sub == "mcp" && a.has("--bridge") && (a.has("--automation-read-root") || a.has("--automation-write-root")) {
return Err("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode".into());
}
Ok(())
}
impl Args {
fn get(&self, k: &str) -> Option<&str> {
self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref())
@@ -373,6 +418,10 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
// Refused before the file is read or any command runs.
if let Some(out) = a.get("--out") {
refuse_overwriting_input(a, file, out)?;
}
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
reported_fonts.extend(ws.iter().cloned());
+137
View File
@@ -56,6 +56,12 @@ fn every_subcommand_answers_help() {
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
}
}
// Help where a flag's value should be is still help, not a missing value or a file named `-h`.
for args in [&["run", "x.png", "--out", "--help"][..], &["commands", "--filter", "-h"], &["mcp", "--bridge", "-h"], &["serve", "--port", "--help"]] {
let o = bin().current_dir(&d).args(args).stdin(Stdio::null()).output().unwrap();
assert_eq!(o.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
assert!(String::from_utf8_lossy(&o.stdout).contains("USAGE"), "{args:?}");
}
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
}
@@ -87,6 +93,22 @@ fn unknown_flags_are_usage_errors() {
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
// A flag can't be a value: a missing value is reported, not filled with the next flag.
let o = bin()
.args(["batch", "--actions"])
.arg(&actions)
.arg("--in")
.arg(&input)
.arg("--out")
.arg(&out_dir)
.args(["--format", "--quality", "80"])
.output()
.unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--format needs a value"));
assert!(!out_dir.exists(), "nothing written");
// `--flag=value` still passes a value that starts with `--`.
ok(bin().args(["commands", "--filter=--json"]));
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
@@ -105,6 +127,81 @@ fn unknown_flags_are_usage_errors() {
assert!(!out.trim().contains('\n'), "compact JSON is one line");
}
fn server_usage_error(args: &[&str], expected: &str) {
let o = bin().args(args).env_remove("PHOTOCRAFT_CONTROL_TOKEN").env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE").stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{args:?}: {err}");
assert!(o.stdout.is_empty(), "usage errors must leave protocol stdout empty: {args:?}");
assert!(err.contains(expected), "{args:?}: expected {expected:?} in {err}");
}
#[test]
fn server_modes_reject_ignored_or_conflicting_options() {
for sub in ["mcp", "serve"] {
server_usage_error(&[sub, "stray.psd"], "does not take positional arguments");
for flag in ["--control-token", "--control-token-file"] {
server_usage_error(&[sub, flag, "unused"], if sub == "mcp" { "requires --bridge" } else { "requires --port" });
}
for flag in ["--automation-read-root", "--automation-write-root"] {
server_usage_error(&[sub, flag, ".", flag, "."], "may only be specified once");
server_usage_error(&[sub, &format!("{flag}=")], "needs a non-empty value");
server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("{flag} needs a value"));
}
let transport = if sub == "mcp" { "--bridge" } else { "--port" };
for flag in [transport, "--control-token", "--control-token-file"] {
server_usage_error(&[sub, flag, "one", &format!("{flag}=two")], "may only be specified once");
server_usage_error(&[sub, flag, ""], "needs a non-empty value");
}
server_usage_error(
&[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"],
"use either --control-token or --control-token-file",
);
}
for flag in ["--automation-read-root", "--automation-write-root"] {
server_usage_error(&["mcp", "--bridge", "127.0.0.1:7878", flag, "."], "configure automation roots on the desktop app");
}
}
/// Each launch line would, before validation, have started a TCP server and written the token file.
#[test]
fn server_validation_does_not_consume_stdio_or_create_files() {
let d = tmp("server-validation");
let token = d.join("must-not-be-created.token");
let other = d.join("other.token");
let token_flag = format!("--control-token-file={}", token.display());
let other_flag = format!("--control-token-file={}", other.display());
for tail in [&["stray"][..], &["--automation-read-root", ".", "--automation-read-root", "."], &["--port", "0"], &[&other_flag]] {
let mut child = bin()
.current_dir(&d)
.args(["serve", "--port", "0", &token_flag])
.args(tail)
.env_remove("PHOTOCRAFT_CONTROL_TOKEN")
.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// Keep stdin open: invalid launch arguments must fail before starting a server.
let input = child.stdin.take().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while child.try_wait().unwrap().is_none() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
if child.try_wait().unwrap().is_none() {
child.kill().unwrap();
child.wait().unwrap();
panic!("{tail:?}: invalid configuration started a server");
}
drop(input);
let o = child.wait_with_output().unwrap();
assert_eq!(o.status.code(), Some(2), "{tail:?}: {}", String::from_utf8_lossy(&o.stderr));
assert!(o.stdout.is_empty(), "{tail:?}");
assert!(!token.exists() && !other.exists(), "{tail:?}: validation must happen before creating credentials");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn convert_png_pcraft_png_is_lossless() {
let d = tmp("convert");
@@ -563,6 +660,46 @@ fn tga_bits_choose_the_targa_depth() {
}
}
/// #7437: `run <file> --out` to that same file, however `--out` is spelt, is refused before the
/// file is read or any command runs, unless `--in-place` asks for it (as `batch` does, #492).
#[test]
fn run_refuses_to_write_over_its_input() {
let d = tmp("run-in-place");
write_png(&d.join("in.png"), 8, 4, 7);
let before = std::fs::read(d.join("in.png")).unwrap();
let run = |out: &Path, extra: &[&str]| {
bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out"]).arg(out).args(extra).output().unwrap()
};
let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
outs.push(PathBuf::from("hard.png"));
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
outs.push(PathBuf::from("soft.png"));
}
for out in &outs {
for extra in [&[][..], &["--format", "png"][..]] {
let o = run(out, extra);
let (stdout, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
assert!(stdout.is_empty(), "--out {} {extra:?} ran commands: {stdout}", out.display());
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "--out {} {extra:?} changed the original", out.display());
}
}
// Another file is written as before, leaving the original alone.
ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "out.png"]));
assert!(d.join("out.png").is_file());
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
// `--in-place` opts in: the inverted result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "in.png", "--in-place"]));
assert_ne!(std::fs::read(d.join("in.png")).unwrap(), before, "--in-place left the original unchanged");
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
/// doesn't name is refused and nothing is written.
#[test]
+1 -1
View File
@@ -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 }
+14 -13
View File
@@ -17,12 +17,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .act, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign",
"afphoto", "afpub",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act", "ase", "af",
"afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -233,20 +233,21 @@ fn browser_languages() -> Vec<String> {
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<Vec<u8>, 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()));
+2
View File
@@ -41,6 +41,8 @@ arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
ron = "0.12"
# Background release checks; HTTPS with Rust TLS, no async runtime.
ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
+1
View File
@@ -74,6 +74,7 @@ mod single_instance;
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
mod updates;
mod window_frame;
use photocraft_engine::Session;
+9 -1
View File
@@ -1,6 +1,11 @@
//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
use photocraft_ui_egui::screen_picker::{Capture, Pending, Service};
// Only the platform capture backends (and the tests) build images and check sizes.
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
use photocraft_ui_egui::screen_picker::MAX_PIXELS;
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
use photocraft_ui_egui::screen_picker::ScreenImage;
pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Service> {
if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
@@ -34,6 +39,7 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Servic
}))
}
#[derive(Clone, Copy)]
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", target_os = "windows", test)), allow(dead_code))]
struct Placement {
index: usize,
x: i32,
@@ -41,9 +47,11 @@ struct Placement {
width: u32,
height: u32,
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option<usize> {
placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
+17 -3
View File
@@ -15,13 +15,13 @@ use std::sync::Arc;
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase",
"kys", "af", "afdesign", "afphoto", "afpub",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act",
"ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -71,6 +71,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Styles", &["asl"]),
("Swatches", &["aco", "act", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
@@ -560,6 +561,12 @@ fn shell_word(s: &str) -> String {
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
}
/// The release-check transport, or `None` inside Flatpak: the sandbox has no network (see the
/// manifest's finish-args) and `flatpak update` owns updates there.
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
flatpak_id.is_none().then(crate::updates::service)
}
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
@@ -624,6 +631,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
beep: Some(platform_beep),
check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()),
clipboard_set_image: Some({
let clip = clip.clone();
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
@@ -903,6 +911,12 @@ mod tests {
);
}
#[test]
fn flatpak_builds_have_no_update_transport() {
assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none());
assert!(update_transport(None).is_some());
}
#[test]
fn xwayland_command_matches_how_photocraft_was_installed() {
// Flatpak: WAYLAND_DISPLAY doesn't reach the sandbox's socket choice; flatpak's flags do.
+126
View File
@@ -0,0 +1,126 @@
//! Native HTTPS transport for release checks. Only public release metadata is requested.
use photocraft_engine::updates_cmds::{CheckFn, CheckResult, parse_release};
use std::time::Duration;
const LATEST: &str = "https://api.github.com/repos/storytold/photocraft/releases/latest";
const MAX_RESPONSE: u64 = 1024 * 1024;
pub fn service() -> CheckFn {
Box::new(|| {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("update check".into())
.spawn(move || {
let result = fetch(LATEST, Duration::from_secs(15));
if let Err(e) = &result {
log::warn!("Update check: {e}");
}
let _ = tx.send(result);
})
.map_err(|e| format!("Could not start update check: {e}"))?;
Ok(rx)
})
}
fn fetch(url: &str, timeout: Duration) -> CheckResult {
// Respect the OS trust store (including managed-network certificates), while still
// validating the certificate chain and server name.
let agent = ureq::Agent::config_builder()
.tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build())
.timeout_global(Some(timeout))
.max_redirects(0)
.build()
.new_agent();
let mut response = match agent
.get(url)
.header("User-Agent", "PhotoCraft-update-check")
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2026-03-10")
.call()
{
Ok(response) => response,
// The repository may not have published a stable release yet.
Err(ureq::Error::StatusCode(404)) => return Ok(None),
Err(ureq::Error::StatusCode(403 | 429)) => return Err("GitHub is limiting update checks. Please try again later.".into()),
Err(e) => return Err(format!("Could not contact the release server: {e}")),
};
if response.status() != 200 {
return Err(format!("Unexpected release server status: {}", response.status()));
}
let bytes = response.body_mut().with_config().limit(MAX_RESPONSE).read_to_vec().map_err(|e| format!("Could not read release response: {e}"))?;
parse_release(&bytes, photocraft_engine::build_info::VERSION)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
fn server(status: &str, body: &str, extra: &str) -> (String, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/latest", listener.local_addr().unwrap());
let response = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra}Connection: close\r\n\r\n{body}", body.len());
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|w| w == b"\r\n\r\n") {
let n = stream.read(&mut chunk).unwrap();
if n == 0 {
break;
}
request.extend_from_slice(&chunk[..n]);
}
let _ = stream.write_all(response.as_bytes());
String::from_utf8(request).unwrap()
});
(url, worker)
}
#[test]
fn fetches_release_metadata_with_generic_headers() {
let (url, worker) = server("200 OK", r#"{"tag_name":"v99.0.0","draft":false,"prerelease":false}"#, "");
let result = fetch(&url, Duration::from_secs(5)).unwrap().unwrap();
assert_eq!(result.version, "99.0.0");
let request = worker.join().unwrap().to_ascii_lowercase();
assert!(request.contains("user-agent: photocraft-update-check\r\n"));
assert!(request.contains("accept: application/vnd.github+json"));
assert!(!request.contains("authorization:") && !request.contains("cookie:"));
}
#[test]
fn handles_missing_release_rate_limits_and_bad_responses() {
for (status, body, extra, expected_none) in [
("404 Not Found", "", "", true),
("403 Forbidden", "", "", false),
("429 Too Many Requests", "", "", false),
("500 Internal Server Error", "", "", false),
("200 OK", "invalid JSON", "", false),
("302 Found", "", "Location: https://untrusted.example/\r\n", false),
] {
let (url, worker) = server(status, body, extra);
let result = fetch(&url, Duration::from_secs(5));
if expected_none {
assert_eq!(result, Ok(None));
} else {
assert!(result.is_err(), "{status}");
}
worker.join().unwrap();
}
}
#[test]
fn caps_response_size_and_times_out() {
let (url, worker) = server("200 OK", &" ".repeat(MAX_RESPONSE as usize + 1), "");
assert!(fetch(&url, Duration::from_secs(5)).is_err());
worker.join().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/latest", listener.local_addr().unwrap());
let start = std::time::Instant::now();
assert!(fetch(&url, Duration::from_millis(100)).is_err());
assert!(start.elapsed() < Duration::from_secs(2));
}
}
+5
View File
@@ -64,3 +64,8 @@ layers because that would lose their group's blending semantics.
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
`mcp` and `serve` reject positional arguments, repeated options, missing or empty values and
options for the wrong mode before starting a server. Explicit control-token flags require
`mcp --bridge` or `serve --port`. Configure automation roots on the desktop process when using
bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md) for examples.
+9
View File
@@ -20,6 +20,15 @@ Bridge to a running desktop application with the same private token file used by
photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file /private/path/control.token
```
Use absolute root paths in MCP client configurations, since a relative root is resolved from the
client's launch directory. In bridge mode the roots are configured on the desktop app, and root
flags on `photocraft-cli mcp --bridge` are rejected. Explicit `--control-token` and
`--control-token-file` flags require `--bridge` and are mutually exclusive; bridge credentials can
also come from `PHOTOCRAFT_CONTROL_TOKEN` or `PHOTOCRAFT_CONTROL_TOKEN_FILE`. Each launch option
except `--help`/`-h` takes one non-empty value and cannot be repeated. Stray positional arguments,
missing values and options for the wrong mode are usage errors (exit 2) reported on stderr before
the server starts.
Tools cover session/document operations, command discovery and execution, batching, and—when bridged—UI inspection and control. Tool schemas improve correctness but are not authorization boundaries.
## From an installed release
+13
View File
@@ -79,6 +79,9 @@ sync_github_name = true
[people.maxm11]
sync_github_name = true
[people.mweinbach]
sync_github_name = true
[people.skraus2]
sync_github_name = true
@@ -251,3 +254,13 @@ sync_github_name = true
[people.benandez]
real_name = "Martin Ball"
[people.ronibalthazar]
sync_github_name = true
[people.batuhangozegu]
real_name = "Batuhan Gözegü"
sync_github_name = false
[people.drunkeninja42]
real_name = "Kushagra Sharma"
+9 -3
View File
@@ -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<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut latest: HashMap<u32, (u64, usize, Option<Box<Entry>>)> = HashMap::new();
let mut names: HashMap<u32, String> = 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 })
+27 -1
View File
@@ -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<Path> {
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<Vec<(usize, f64)>> {
.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"));
}
}
+22 -5
View File
@@ -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<Document, Error> {
@@ -304,6 +306,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
child_slots: usize,
name_bytes: usize,
active: HashSet<ObjId>,
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<Option<Node>, 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…)");
}
+13 -7
View File
@@ -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<u8> = 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;
+2 -1
View File
@@ -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)?;
+11
View File
@@ -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
+8 -2
View File
@@ -26,11 +26,17 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
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
}
+1
View File
@@ -21,4 +21,5 @@ rustfft = "6.4.1"
rayon = { workspace = true }
[dev-dependencies]
photocraft-testkit = { workspace = true }
serde_json = { workspace = true }
+57 -7
View File
@@ -1,13 +1,33 @@
//! Timing for the heavier second-batch filters on a ~24 MP (6000×4000) RGBA8 layer.
//! `cargo run --release -p photocraft-algo --example bench_filters2 [filter-name-substring]`
//!
//! `cargo run --release -p photocraft-algo --example bench_filters2 [name-substring] [--reps N] [--quick] [--json <file>]`
//!
//! `--json` writes the report `cargo xtask perf` reads: `{"rows": [{"name", "samples_ms"}]}`.
//! `--quick` runs the list on a 1500×1000 layer instead, for `cargo xtask perf --quick`.
use photocraft_algo::*;
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn main() {
let only = std::env::args().nth(1);
let (w, h) = (6000, 4000);
let a: Vec<String> = std::env::args().skip(1).collect();
let flag = |k: &str| a.iter().position(|v| v == k);
let value = |k: &str| flag(k).and_then(|i| a.get(i + 1)).cloned();
let mut only: Option<String> = None;
let mut i = 0;
while i < a.len() {
match a[i].as_str() {
"--json" | "--reps" => i += 2,
"--quick" => i += 1,
_ => {
only.get_or_insert_with(|| a[i].clone());
i += 1;
}
}
}
let reps: usize = value("--reps").and_then(|v| v.parse().ok()).unwrap_or(1).max(1);
let quick = flag("--quick").is_some();
let (w, h) = if quick { (1500, 1000) } else { (6000, 4000) };
let r = Rect::new(0, 0, w, h);
let mut s = Surface::new(PixelFormat::RGBA8);
// Busy deterministic content (gradients plus texture) so edge-aware filters do real work.
@@ -55,19 +75,49 @@ fn main() {
},
FilterParams::Relight { angle: 45.0, elevation: 40.0, intensity: 40.0, ambient: 55.0, warmth: 0.0, softness: 25.0 },
];
let mut rows: Vec<(String, Vec<f64>)> = Vec::new();
for p in cases {
if only.as_deref().is_some_and(|o| !p.label().to_lowercase().contains(&o.to_lowercase())) {
continue;
}
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
// Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle.
let name = match p {
FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()),
_ => p.label().to_string(),
};
println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed());
drop(out);
let mut samples = Vec::with_capacity(reps);
for _ in 0..reps {
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
let ms = t.elapsed().as_secs_f64() * 1e3;
samples.push(ms);
drop(out);
}
let mut sorted = samples.clone();
sorted.sort_by(f64::total_cmp);
let median = match sorted.len() % 2 {
1 => sorted[sorted.len() / 2],
_ => (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0,
};
println!("{name:<18} {w}x{h} RGBA8: {median:.1} ms{reps_note}", reps_note = if reps > 1 { format!(" (median of {reps})") } else { String::new() });
rows.push((name, samples));
}
if let Some(path) = value("--json") {
// Two fields per row and ASCII filter labels: hand-written rather than a serde dependency
// this crate does not otherwise need.
let body: Vec<String> = rows
.iter()
.map(|(name, samples)| {
let s: Vec<String> = samples.iter().map(|v| format!("{v:.4}")).collect();
format!("{{\"name\":\"{name}\",\"samples_ms\":[{}]}}", s.join(","))
})
.collect();
let text = format!("{{\"rows\":[{}]}}", body.join(","));
if let Err(e) = std::fs::write(&path, text) {
eprintln!("write {path}: {e}");
std::process::exit(1);
}
println!("report: {path}");
}
}
+60 -17
View File
@@ -114,6 +114,21 @@ struct Variant {
ok: Vec<bool>,
}
/// 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<Variant, Cancelled> {
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<Variant, Cancelled> {
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<bool> = 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<Variant> = 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 &deg 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<Variant> = 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<f32>>, Vec<Px>) = 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<f32>>, Vec<Px>) = 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.
+6 -1
View File
@@ -112,6 +112,10 @@ pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: Sph
})
}
/// The most Wave generators the renderer models: it clamps the setting to this, so a file asking
/// for more (the command allows up to 999) would render differently from how it was authored.
pub const MAX_WAVE_GENERATORS: u32 = 64;
/// Wave generator settings.
pub(crate) struct WaveSpec {
pub generators: u32,
@@ -137,8 +141,9 @@ fn shape(t: WaveType, phase: f32) -> f32 {
}
}
/// Wave: displace `src` over `out` by the average of `w.generators` seeded waves (at most `MAX_WAVE_GENERATORS`) along each axis.
pub(crate) fn wave(src: &Image, out: Rect, ctx: &Ctx, w: WaveSpec) -> Vec<f32> {
let n = w.generators.clamp(1, 64);
let n = w.generators.clamp(1, MAX_WAVE_GENERATORS);
let lerp = |(a, b): (f32, f32), t: f32| a + (b - a).max(0.0) * t;
let gens: Vec<(f32, f32, f32, f32)> = (0..n)
.map(|i| {
+52 -4
View File
@@ -87,6 +87,8 @@ impl Tiff<'_> {
}
}
fn text(&self, typ: u16, count: u32, field: usize) -> Option<String> {
#[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<usize> = 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| {
+57
View File
@@ -28,6 +28,18 @@ impl RgbHistogram {
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
// Camera Raw counts its whole proxy (~450k pixels) on every slider step, so the count
// runs in blocks across cores (#211, P36). Integer sums don't depend on the order the
// blocks finish in, so the result is the same as one pass over all pixels.
#[cfg(not(target_arch = "wasm32"))]
if pixels.len() >= PARALLEL_PIXELS {
use rayon::prelude::*;
return pixels.par_chunks(PARALLEL_PIXELS / 2).map(Self::count).reduce(Self::default, Self::merge);
}
Self::count(pixels)
}
fn count(pixels: &[[f32; 4]]) -> Self {
let mut h = Self::default();
for pixel in pixels {
if !pixel[3].is_finite() {
@@ -68,8 +80,29 @@ impl RgbHistogram {
}
h
}
/// Sum of two block counts. Saturating like the counters themselves.
#[cfg(not(target_arch = "wasm32"))]
fn merge(mut self, other: Self) -> Self {
let add = |a: &mut [u64], b: &[u64]| a.iter_mut().zip(b).for_each(|(a, b)| *a = a.saturating_add(*b));
for (a, b) in self.channels.iter_mut().zip(&other.channels) {
add(a, b);
}
add(&mut self.underflow, &other.underflow);
add(&mut self.overflow, &other.overflow);
add(&mut self.shadows, &other.shadows);
add(&mut self.highlights, &other.highlights);
self.samples = self.samples.saturating_add(other.samples);
self.transparent = self.transparent.saturating_add(other.transparent);
self.invalid = self.invalid.saturating_add(other.invalid);
self
}
}
/// Below this many pixels one pass is faster than splitting the count across cores.
#[cfg(not(target_arch = "wasm32"))]
const PARALLEL_PIXELS: usize = 64 * 1024;
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
/// Transparent and non-finite pixels clip nothing.
@@ -94,6 +127,30 @@ pub fn clipping(p: [f32; 4]) -> (u8, u8) {
mod tests {
use super::*;
// The parallel path, and its threshold, exist only off wasm32.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn matches_the_single_pass_on_every_domain_and_across_block_edges() {
// Every special value at every channel, plus a dense ramp through each bin's rounding
// edge; lengths around the parallel threshold and its block size.
let special = [0.0, -0.0, 1.0, -1e-7, 1.0 + 1e-7, 0.5 / 255.0, 254.5 / 255.0, -3.0, 4.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MIN_POSITIVE];
let mut pool: Vec<[f32; 4]> = Vec::new();
for (i, &v) in special.iter().enumerate() {
for alpha in [1.0, 0.5, 0.0, -1.0, f32::NAN] {
pool.push([v, special[(i + 3) % special.len()], 0.25, alpha]);
pool.push([0.75, v, special[(i + 5) % special.len()], alpha]);
}
}
pool.extend((0..4096).map(|i| {
let v = i as f32 / 4095.0 * 1.2 - 0.1;
[v, 1.0 - v, (v * 7.0).fract(), 1.0]
}));
for len in [0, 1, PARALLEL_PIXELS / 2 + 1, PARALLEL_PIXELS - 1, PARALLEL_PIXELS, 3 * PARALLEL_PIXELS + 17] {
let pixels: Vec<_> = (0..len).map(|i| pool[(i * 7919) % pool.len()]).collect();
assert_eq!(RgbHistogram::from_rgba(&pixels), RgbHistogram::count(&pixels), "{len} pixels");
}
}
#[test]
fn exact_bins_and_channel_totals() {
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
+12
View File
@@ -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<Bounds> {
// 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<Option<(usize, usize)>> = 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<f32> {
let mut rng = Rng::new(7);
+23 -11
View File
@@ -86,6 +86,7 @@ pub mod warp;
pub mod wideangle;
pub use artistic::{GALLERY_CATEGORIES, GalleryEffect, GalleryFilter, GalleryParam, GalleryParamKind};
pub use distort::MAX_WAVE_GENERATORS;
pub use image::{Edge, Image};
pub use params_ext::*;
@@ -560,7 +561,7 @@ pub enum FilterParams {
impl FilterParams {
/// Pixels read outside an output tile.
pub fn halo(&self) -> Halo {
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
match self {
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
@@ -570,9 +571,9 @@ impl FilterParams {
| FilterParams::Median { radius }
| FilterParams::DustAndScratches { radius, .. }
| FilterParams::Minimum { radius, .. }
| FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1),
| FilterParams::Maximum { radius, .. } => Halo::Radius((radius.max(0.0).ceil() as i32).saturating_add(1)),
FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2),
FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1),
FilterParams::Mosaic { cell_size } => Halo::Radius((cell_size.max(1.0).ceil() as i32).saturating_add(1)),
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
FilterParams::FindEdges => Halo::Radius(1),
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
@@ -697,7 +698,7 @@ impl FilterParams {
/// Halos of the second filter batch.
fn halo_ext(p: &FilterParams) -> Halo {
let r = |v: f32| Halo::Radius(v.max(0.0).ceil() as i32 + 1);
let r = |v: f32| Halo::Radius((v.max(0.0).ceil() as i32).saturating_add(1));
match p {
FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0),
FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0),
@@ -718,8 +719,9 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
FilterParams::SmartBlur { radius, .. } => r(*radius),
FilterParams::LensBlur { radius, .. } => r(*radius),
// The kernels clamp the radius to 100 px: size the source window by what they use.
FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)),
FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)),
FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)),
FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)),
FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))),
@@ -998,10 +1000,18 @@ pub fn apply_in_with(
}
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
/// re-read margin (and its cost) below ~2× the tile area.
/// re-read margin (and its cost) below ~2× the tile area. The radius is input-derived, so the
/// halo arithmetic is done in `i64` and the result capped: an `i32::MAX` radius must not
/// overflow in debug builds, and must not produce a tile bigger than the hard cap.
fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
match params.halo_for(bounds) {
Halo::Radius(r) => TILE.max((2 * r + 63) / 64 * 64).min(2048),
// The radius is input-derived: widen to i64 before the arithmetic and cap the
// conversion, so an i32::MAX radius cannot overflow (debug panic) and cannot make a
// tile bigger than the hard cap below (131_072 = 2048 * 64, one tile row).
Halo::Radius(r) => {
let halo = ((2 * i64::from(r) + 63) / 64 * 64).min(131_072);
TILE.max(halo as i32).min(2048)
}
Halo::Bounds => TILE,
}
}
@@ -1095,14 +1105,16 @@ fn apply_tiled_with_impl(
return None;
}
let halo = params.halo_for(bounds);
if matches!(params, FilterParams::UnsharpMask { .. }) {
let Halo::Radius(reach) = halo else { return None };
// Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is
// caller-supplied, so a short parameter set can ask for a huge source window per tile.
if let Halo::Radius(reach) = halo {
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
// Reject hostile source-window sizes before Image::read allocates them.
// Ordinary filter parameters and automatic tiles fit these bounds.
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > 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::<f32>())? > isize::MAX as usize || too_many_tiles {
return None;
}
}
+88 -25
View File
@@ -191,6 +191,9 @@ struct StrokeCursor {
/// 125,000); smaller ones are not worth the hand-off.
const PARALLEL_DAB_NODES: usize = 16_384;
/// Bound completed output buffers independently of the document's size.
const RENDER_BATCH_TILES: usize = 64;
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
@@ -209,11 +212,20 @@ pub fn auto_cell(bounds: Rect) -> f64 {
if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 }
}
/// Most nodes of a field's displacement grid (12 bytes each with the freeze mask): 192 MB.
const MAX_FIELD_NODES: usize = 1 << 24;
impl LiquifyField {
pub fn new(bounds: Rect, cell: f64) -> Self {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
let mut cell = cell.clamp(1.0, 64.0);
let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (f64::from(bounds.height()) / cell).ceil() as usize + 2);
let (mut w, mut h) = nodes(cell);
// The extent is the document canvas, which a file declares: past the node budget the
// field gets coarser (it is interpolated anyway) instead of asking for gigabytes.
while w.saturating_mul(h) > MAX_FIELD_NODES {
cell *= 2.0;
(w, h) = nodes(cell);
}
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
}
@@ -584,14 +596,11 @@ impl LiquifyField {
}
/// Catmull-Rom (bicubic) displacement at grid coordinates.
fn sample_cubic(&self, gx: f64, gy: f64, wx: &[f64; 4], wy: &[f64; 4]) -> [f64; 2] {
let (ix, iy) = (gx.floor() as isize, gy.floor() as isize);
fn sample_cubic(&self, xs: &[(usize, f64); 4], ys: &[(usize, f64); 4]) -> [f64; 2] {
let mut acc = [0.0f64; 2];
for (jj, wyj) in wy.iter().enumerate() {
let y = (iy - 1 + jj as isize).clamp(0, self.h as isize - 1) as usize;
for &(y, wyj) in ys {
let row = &self.d[y * self.w..(y + 1) * self.w];
for (ii, wxi) in wx.iter().enumerate() {
let x = (ix - 1 + ii as isize).clamp(0, self.w as isize - 1) as usize;
for &(x, wxi) in xs {
let k = wxi * wyj;
acc[0] += f64::from(row[x][0]) * k;
acc[1] += f64::from(row[x][1]) * k;
@@ -651,6 +660,13 @@ fn catmull_rom(t: f64) -> [f64; 4] {
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
}
/// A field axis's clamped nodes and weights, reusable across a tile's rows or columns.
fn cubic_taps(g: f64, len: usize) -> [(usize, f64); 4] {
let i = g.floor() as isize;
let weights = catmull_rom(g - g.floor());
std::array::from_fn(|k| ((i - 1 + k as isize).clamp(0, len as isize - 1) as usize, weights[k]))
}
/// Resamples `src` through the field: inside `field.bounds` each pixel shows the source at
/// `p + d(p)` (bicubic field, bicubic premultiplied source, edge pixels repeat past the field
/// bounds); everything outside the bounds is kept. Same format as `src`.
@@ -671,19 +687,24 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
let h = t.height() as usize;
// Field per pixel (bicubic), rows share their y weights.
// Field per pixel (bicubic), with axis taps shared across rows and columns.
let mut disp = vec![[0.0f64; 2]; w * h];
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut moved = false;
let columns: Vec<_> = (0..w)
.map(|x| {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
(px, cubic_taps(gx, field.w))
})
.collect();
for y in 0..h {
let py = f64::from(t.y0) + y as f64 + 0.5;
let gy = (py - f64::from(b.y0) - 0.5) / field.cell;
let wy = catmull_rom(gy - gy.floor());
for x in 0..w {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
let wx = catmull_rom(gx - gx.floor());
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
let ys = cubic_taps(gy, field.h);
for (x, (px, xs)) in columns.iter().enumerate() {
let px = *px;
let mut d = field.sample_cubic(xs, &ys);
if has_freeze {
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
if mask > 0.0 {
@@ -765,15 +786,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
}
Some((*t, outp))
};
#[cfg(not(target_arch = "wasm32"))]
let done: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
tiles.par_iter().filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().filter_map(work).collect();
for (r, v) in done {
out.write_region(r, &v);
for batch in tiles.chunks(RENDER_BATCH_TILES) {
for (r, v) in par::map(batch.len(), true, |i| batch.get(i).and_then(work)).into_iter().flatten() {
out.write_region(r, &v);
}
}
if alpha {
out.prune();
@@ -1122,6 +1138,15 @@ mod tests {
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_huge_canvas_gets_a_coarser_field_not_a_huge_one() {
// 400000² px at 2 px per node is 4·10¹⁰ nodes.
let f = LiquifyField::new(Rect::new(0, 0, 400_000, 400_000), 2.0);
assert!(f.w * f.h <= MAX_FIELD_NODES && f.d.len() == f.w * f.h && f.freeze.len() == f.d.len());
assert!(f.cell > 64.0);
assert_eq!(LiquifyField::new(Rect::new(0, 0, 100, 100), 2.0).cell, 2.0);
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
@@ -1300,6 +1325,44 @@ mod tests {
}
}
#[test]
fn a_wide_integer_translation_keeps_channels_and_tile_edges_at_every_format() {
let b = Rect::new(13, -1, 13 + 70 * 256, 1);
let w = b.width() as usize;
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let format = PixelFormat::new(mode, depth, true);
let n = format.channels();
let mut data = Vec::new();
for i in 0..w * 2 {
let alpha = if i % 13 == 0 { 0.0 } else { 0.5 };
for c in 0..mode.color_channels() {
data.push(if alpha == 0.0 { 0.0 } else { ((i * 19 + c * 43) % 251 + 2) as f32 / 255.0 });
}
data.push(alpha);
}
let mut source = Surface::new(format);
source.write_region(b, &data);
source.write_pixel(b.x0 - 1, b.y0 - 1, &vec![0.75; n]);
let before = source.read_region(b);
let mut field = LiquifyField::new(b, 2.5);
field.d.fill([-1.0, 0.0]);
let output = apply_liquify(&source, &field);
let after = output.read_region(b);
let tolerance = if depth == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 };
for (original, moved) in before.chunks_exact(w * n).zip(after.chunks_exact(w * n)) {
for (x, pixel) in moved.chunks_exact(n).enumerate() {
let offset = x.saturating_sub(1) * n;
for (actual, expected) in pixel.iter().zip(&original[offset..offset + n]) {
assert!((actual - expected).abs() <= tolerance, "{mode:?}/{depth:?} at column {x}");
}
}
}
assert_eq!(output.pixel(b.x0 - 1, b.y0 - 1), source.pixel(b.x0 - 1, b.y0 - 1));
}
}
}
#[test]
fn no_strokes_is_identity_at_every_depth() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+5 -6
View File
@@ -512,12 +512,11 @@ fn direction_cost(tp: [f32; 2], tq: [f32; 2], dx: i32, dy: i32, len: f32) -> f32
if tp == [0.0; 2] || tq == [0.0; 2] {
return NEUTRAL_DIRECTION;
}
let mut l = [dx as f32 / len, dy as f32 / len];
if tp[0] * l[0] + tp[1] * l[1] < 0.0 {
l = [-l[0], -l[1]];
}
let dp = (tp[0] * l[0] + tp[1] * l[1]).clamp(-1.0, 1.0);
let dq = (l[0] * tq[0] + l[1] * tq[1]).clamp(-1.0, 1.0);
// An edge tangent is an axis, not a directed vector: reversing the strongest channel's
// gradient must not make an otherwise straight edge expensive to follow.
let l = [dx as f32 / len, dy as f32 / len];
let dp = (tp[0] * l[0] + tp[1] * l[1]).abs().clamp(0.0, 1.0);
let dq = (l[0] * tq[0] + l[1] * tq[1]).abs().clamp(0.0, 1.0);
2.0 / (3.0 * PI) * (dp.acos() + dq.acos())
}
+33
View File
@@ -55,6 +55,39 @@ fn snaps_to_the_most_prominent_edge() {
assert!((p[0] - 18.0).abs() < 0.01, "{p:?}");
}
#[test]
fn gradient_polarity_does_not_make_a_straight_edge_look_like_a_turn() {
// A gradient can reverse across adjacent pixels when another colour channel becomes the
// strongest one. The edge tangent is an axis, so its sign must not change the path cost.
let forward = direction_cost([1.0, 0.0], [1.0, 0.0], 1, 0, 1.0);
let reversed = direction_cost([1.0, 0.0], [-1.0, 0.0], 1, 0, 1.0);
assert!(forward < 1e-6, "aligned edge: {forward}");
assert!(reversed < 1e-6, "same edge with reversed gradient polarity: {reversed}");
}
#[test]
fn traces_a_straight_edge_through_a_gradient_polarity_change() {
let read = Cell::new(0);
// The contrast stays high, but the edge changes from dark-to-light to light-to-dark halfway
// down. The horizontal seam intersects it; the guide stays on the vertical contour.
let mut f = image(
|x, y| {
let left = x < 32;
let top = y < 32;
let white = left != top;
grey(if white { 255 } else { 0 })
},
&read,
);
let mut t = Tracer::new(Rect::new(0, 0, 64, 64));
let path = t.trace(&mut f, [32.0, 5.0], [32.0, 59.0], &[[32.0, 32.0]], Settings::default());
assert_eq!(path.first(), Some(&[32.0, 5.0]));
assert_eq!(path.last(), Some(&[32.0, 59.0]));
for p in &path {
assert!((p[0] - 32.0).abs() < 1.0, "left the high-contrast edge: {p:?}; {path:?}");
}
}
#[test]
fn trace_follows_a_curved_edge() {
let read = Cell::new(0);
+19 -4
View File
@@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec<f32>
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<F>(
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + 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;
+259 -82
View File
@@ -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<f32> = 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<i8>,
/// Coverage × 127 per painted tile, row-major over the tile.
tiles: BTreeMap<TileCoord, Vec<i8>>,
/// 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<BTreeSet<TileCoord>> {
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<Rect> = 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<BTreeMap<TileCoord, Vec<u8>>> {
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let mut tiles: BTreeMap<TileCoord, Vec<u8>> = 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<Surface> {
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<Item = ((f32, f32), (f32, f32))> {
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<EdgeBrush> {
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<f32> = Vec::new();
let mut tiles: BTreeMap<TileCoord, Vec<i8>> = 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::<TS>().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<f32> {
(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());
}
}
+77 -3
View File
@@ -29,6 +29,24 @@ pub(crate) struct OilSpec {
fn stroke_len(stylization: f32) -> f32 {
1.5 + stylization.clamp(0.1, 10.0) * 2.0
}
/// `f32::round` (half away from zero) as an integer, without the `roundf` call `f32::round`
/// makes on the baseline x86-64 target. The sum is taken in `f64`, where `v ± 0.5` is exact for
/// every `f32` the cast does not saturate (an `f32` sum is not: `0.49999997 + 0.5` rounds up to
/// `1.0`), so the result equals `v.round() as i32` for every `f32`, NaN (0) and the saturating
/// ends included.
#[inline]
fn round_i32(v: f32) -> i32 {
let v = f64::from(v);
(v + 0.5f64.copysign(v)) as i32
}
/// [`round_i32`] clamped into `0..n`.
#[inline]
fn round_index(v: f32, n: usize) -> usize {
round_i32(v).clamp(0, n as i32 - 1) as usize
}
fn tensor_sigma(scale: f32) -> f32 {
0.8 + scale.clamp(0.1, 10.0) * 0.6
}
@@ -93,7 +111,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let sample = |x: f32, y: f32, acc: &mut [f32], k: f32| {
let x = x.clamp(0.0, (ww - 1) as f32);
let y = y.clamp(0.0, (wh - 1) as f32);
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
let (x0, y0) = (x as usize, y as usize);
let (x1, y1) = ((x0 + 1).min(ww - 1), (y0 + 1).min(wh - 1));
let (ax, ay) = (x - x0 as f32, y - y0 as f32);
let w00 = (1.0 - ax) * (1.0 - ay) * k;
@@ -119,7 +137,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let (mut qx, mut qy) = (dx, dy);
let (mut px, mut py) = (0.0f32, 0.0f32);
for k in 1..=steps {
let ti = (qy.round().clamp(0.0, (wh - 1) as f32) as usize) * ww + qx.round().clamp(0.0, (ww - 1) as f32) as usize;
let ti = round_index(qy, wh) * ww + round_index(qx, ww);
let (mut tx, mut ty) = tangent[ti];
if k == 1 {
tx *= dir;
@@ -135,7 +153,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let wk = 1.0 - k as f32 / (steps + 1) as f32;
sample(qx, qy, &mut acc[..n], wk);
wsum += wk;
nsum += wk * hash01(win.x0 + qx.round() as i32, win.y0 + qy.round() as i32, 71, 0);
nsum += wk * hash01(win.x0 + round_i32(qx), win.y0 + round_i32(qy), 71, 0);
}
}
let o = (ry * rw + rx) * n;
@@ -197,3 +215,59 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
}
res
}
#[cfg(test)]
mod tests {
use super::{round_i32, round_index};
/// The LIC looks up tangents and bristle noise by rounded sample position; the replacements
/// for `f32::round` must agree with it over every position a tile can produce, and agree with
/// `f32::clamp` at the ends, including non-finite input.
#[test]
fn rounding_matches_the_standard_library() {
let mut v = -40.0f32;
while v <= 2100.0 {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v})");
for n in [1usize, 2, 33, 256, 2048] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v}, {n})");
}
v += 0.25;
}
// The binade crossings where an `f32` sum `v + 0.5` rounds up (0.5 - 2^-25), the ties, and
// the largest non-integer `f32`.
let below_half = f32::from_bits(0x3eff_ffff);
for v in [below_half, -below_half, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 8_388_607.5, -8_388_607.5, 8_388_608.0, 3.0e9, -3.0e9] {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v:e})");
for n in [1usize, 2, 33] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v:e}, {n})");
}
}
assert_eq!(round_i32(f32::NAN), f32::NAN.round() as i32);
for n in [1usize, 8] {
assert_eq!(round_index(f32::NEG_INFINITY, n), (f32::NEG_INFINITY.clamp(0.0, (n - 1) as f32)) as usize);
assert_eq!(round_index(f32::INFINITY, n), ((n - 1) as f32) as usize);
}
}
/// Every `f32` bit pattern in [-4096, 4096] (both signs, about 2.3e9 values, split across
/// threads) rounds exactly like `f32::round`.
#[test]
fn rounding_matches_the_standard_library_for_every_f32_up_to_4096() {
use rayon::prelude::*;
let last = 4096.0f32.to_bits();
let bad = (0..=last >> 20)
.into_par_iter()
.filter_map(|hi| {
let lo = hi << 20;
let hi_end = (lo | 0xf_ffff).min(last);
(lo..=hi_end).find_map(|bits| {
let v = f32::from_bits(bits);
[v, -v].into_iter().find(|v| round_i32(*v) != v.round() as i32)
})
})
.min_by(|a, b| a.abs().total_cmp(&b.abs()));
assert_eq!(bad, None, "round_i32 differs from f32::round");
}
}
+61 -12
View File
@@ -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<f64>) {
/// 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<f64>) {
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::<f32>() + t.outside;
assert!((total - 1.0).abs() < 1e-3, "{total}");
}
}
}
}
#[test]
fn transparent_edges_do_not_darken() {
let mut s = Surface::new(PixelFormat::RGBA32F);
+4 -1
View File
@@ -761,10 +761,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
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<f32> {
+291
View File
@@ -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<BTreeSet<TileCoord>> {
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<Surface> {
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<Edge> = 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<f32>| {
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<i32> = 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<Tile>)> {
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<TileCoord> = 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<f32> = 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<Rect> = cols.iter().map(x_of).collect();
let mut acc = vec![0.0f32; cols.len() * TS];
let mut tiles: Vec<Tile> = 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::<TS>().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<u8> = 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::<BTreeSet<_>>();
assert!(s.tile_count() == 200 * 200 && shared.len() == 1, "{} tiles, {} distinct", s.tile_count(), shared.len());
}
}
+80
View File
@@ -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<u8> = (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<TileCoord> = 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);
}
}
+12 -1
View File
@@ -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,
+9
View File
@@ -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));
}
+16
View File
@@ -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) {
+28 -1
View File
@@ -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<Rect> = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect();
let mut bins: Vec<Vec<[usize; 3]>> = 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
@@ -0,0 +1,107 @@
//! Final-render coverage for #7989, extending the boundary fix in #8012.
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn bounds() -> Rect {
// Cross the tile boundary at x=256, with a nonzero origin on both axes.
Rect::new(192, -32, 320, 96)
}
fn source(mode: ColorMode, sample: SampleType) -> Surface {
let format = PixelFormat::new(mode, sample, true);
let mut pixels = Vec::with_capacity(128 * 128 * format.channels());
for y in 0..128 {
for x in 0..128 {
let alpha = if (48..52).contains(&x) { 0.0 } else { ((x + 3 * y) % 5 + 1) as f32 / 5.0 };
for channel in 0..mode.color_channels() {
pixels.push(if alpha == 0.0 { 0.0 } else { ((x * 19 + y * 7 + channel * 43) % 251 + 2) as f32 / 255.0 });
}
pixels.push(alpha);
}
}
let mut source = Surface::new(format);
source.write_region(bounds(), &pixels);
source
}
fn warp(points: &[[f64; 2]], size: f64, pressure: f64) -> LiquifyStroke {
let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, size);
stroke.pressure = pressure;
stroke.points = points.iter().map(|point| point.to_vec()).collect();
stroke
}
fn freeze_left() -> LiquifyStroke {
let mut stroke = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
stroke.amount = Some(1.0);
stroke.points = vec![vec![192.0, -32.0], vec![256.0, -32.0], vec![256.0, 96.0], vec![192.0, 96.0]];
stroke
}
fn check_frozen_output(prior_warp: bool) {
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let source = source(mode, sample);
for cell in [2.5, 4.0] {
let mut strokes = Vec::new();
if prior_warp {
strokes.push(warp(&[[238.0, 20.0], [261.0, 28.0]], 64.0, 55.0));
}
strokes.push(freeze_left());
let mut field = LiquifyField::from_strokes(bounds(), cell, &strokes);
// #8012 establishes the bilinear mapping when the mask is applied.
// This test checks later edits, not exact pre-mask cubic preservation.
let before = apply_liquify(&source, &field);
assert_eq!(before.format(), source.format());
assert_eq!(before == source, !prior_warp, "the fixture must retain an earlier warp and keep a fresh mask-only operation unchanged");
strokes.push(warp(&[[268.0, 32.0], [252.0, 32.0]], 48.0, 80.0));
field.apply_stroke(&strokes[strokes.len() - 1]);
let after = apply_liquify(&source, &field);
assert_eq!(after.format(), source.format());
let (mut protected, mut fractional_alpha, mut unfrozen_changes) = (0, 0, 0);
let mut frozen_warped = 0;
for y in bounds().y0..bounds().y1 {
for x in bounds().x0..bounds().x1 {
let pixel = before.pixel(x, y);
if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 1.0 {
protected += 1;
frozen_warped += usize::from(pixel != source.pixel(x, y));
let alpha = pixel[mode.color_channels()];
fractional_alpha += usize::from(alpha > 0.0 && alpha < 1.0);
// An unchanged F32 pixel can round on premultiply/unpremultiply.
// Integer-depth pixels must remain exactly equal.
let tolerance = if sample == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 };
for (channel, (actual, expected)) in after.pixel(x, y).iter().zip(&pixel).enumerate() {
assert!(
(actual - expected).abs() <= tolerance,
"{mode:?}/{sample:?}, mesh {cell}, prior warp {prior_warp}: frozen channel {channel} changed at {x},{y}: {expected} -> {actual}"
);
}
} else if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 0.0 {
unfrozen_changes += usize::from(after.pixel(x, y) != pixel);
}
}
}
assert!(protected > 7_000 && fractional_alpha > 3_000, "the fixture must cover frozen pixels with fractional alpha");
assert_eq!(frozen_warped > 0, prior_warp, "the fixture must retain the earlier warp inside the fully frozen region");
assert!(unfrozen_changes > 0, "the adjacent unfrozen region must still move");
let replay = LiquifyField::from_strokes(bounds(), cell, &strokes);
assert!(field == replay, "stroke replay must reproduce the field exactly");
assert!(apply_liquify(&source, &replay) == after, "stroke replay must reproduce the final pixels exactly");
}
}
}
}
#[test]
fn fresh_frozen_output_preserves_color_and_alpha_across_formats() {
check_frozen_output(false);
}
#[test]
fn existing_frozen_mapping_survives_adjacent_warps_across_formats() {
check_frozen_output(true);
}
+1
View File
@@ -1,5 +1,6 @@
//! The crate's integration tests, linked into one test binary.
mod liquify_freeze_formats;
mod liquify_source_footprint;
mod retouch_blur_exact;
mod warp_clipped;
+34 -3
View File
@@ -152,6 +152,28 @@ impl Headless {
})
}
/// Replace a smart object's contents with a file from the read root: the capability-scoped
/// `layer.smartObjects.replaceContents`, which takes an ambient `path` and is refused in
/// automation (#8061). `params` is the command's own (`layer`).
pub fn replace_smart_object(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
self.on_doc(index, |this| {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
return this.command_run("layer.smartObjects.replaceContents", Value::Object(opts));
}
let bytes = match &this.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
}
};
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
this.sync_jobs();
Ok(photocraft_engine::smart_cmds::replace_contents_from_bytes(&mut this.session, &Value::Object(opts), name, bytes)?)
})
}
/// Export each exported layer of a document as its own file beneath the write root: the
/// capability-scoped `file.export.layersToFiles`, which takes an ambient `dir` and is
/// therefore refused in automation (#3216). The bytes come from the command's own core, so
@@ -171,10 +193,16 @@ impl Headless {
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| {
let report = file_cmds::export_layers_report_with(&this.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
let (written, skipped) = match report {
Ok(report) => (Ok(report.files), report.skipped),
Err(error) => (Err(error), Vec::new()),
};
let mut result = exported(dir, written, &mut outputs)?;
result["skipped"] = json!(skipped);
Ok(result)
})
}
@@ -261,7 +289,10 @@ impl Headless {
files::save(&doc, &target, format, opts, Some(writer))?
}
Filesystem::Workspace(workspace) => {
let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?;
// Only the target is granted: a linked smart object's stored path is document
// data, so PSD export writes that layer as pixels instead of embedding the file.
let opts = ExportOptions { read_linked_files: false, ..opts.clone() };
let (bytes, warnings) = files::save_bytes(&doc, target_text, format, &opts)?;
workspace.write(target_text, &bytes)?;
warnings
}
+357 -16
View File
@@ -10,6 +10,8 @@
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
//! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope,
//! the counterpart of the refused ambient `file.placeEmbedded`),
//! `doc.replaceSmartObject {path, layer?, index?}` (#8061: the same scope for
//! `layer.smartObjects.replaceContents`),
//! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`,
//! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`,
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
@@ -22,7 +24,9 @@
use std::io::{BufRead, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use base64::Engine as _;
use serde_json::{Value, json};
@@ -43,6 +47,7 @@ pub const METHODS: &[&str] = &[
"session.list",
"doc.open",
"doc.place",
"doc.replaceSmartObject",
"doc.new",
"doc.save",
"doc.exportLayers",
@@ -108,6 +113,13 @@ impl Headless {
opts.remove("index");
self.place(index_of(&p)?, path, &Value::Object(opts))
}
"doc.replaceSmartObject" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.replaceSmartObject needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.replace_smart_object(index_of(&p)?, path, &Value::Object(opts))
}
"doc.new" => self.command_run("file.new", p),
"doc.save" => {
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
@@ -315,32 +327,67 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
}
}
/// Headless TCP daemons with no clients should eventually release their listening port (#3682).
const TCP_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// Serve authenticated JSON lines on a loopback TCP address with one shared session.
/// Refuses non-loopback addresses and caps active connections and request bytes.
/// Refuses non-loopback addresses, caps connections and request bytes, and closes the listener
/// after an idle period with no connections. Active clients are never evicted by this timer.
pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> {
serve_tcp_until_idle(addr, h, token, ready, TCP_IDLE_TIMEOUT)
}
fn serve_tcp_until_idle(
addr: &str,
h: Arc<Mutex<Headless>>,
token: String,
ready: impl FnOnce(std::net::SocketAddr),
idle: Duration,
) -> Result<(), AutomationError> {
let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?;
let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?;
if !local.ip().is_loopback() {
return Err(bad(format!("{addr} is not a loopback address")));
}
listener.set_nonblocking(true).map_err(|e| AutomationError::Io(e.to_string()))?;
ready(local);
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let token = Arc::new(token);
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Some(permit) = limiter.try_acquire() else {
let _ = configure_stream(&stream);
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
continue;
};
let h = h.clone();
let token = Arc::clone(&token);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
});
let active = Arc::new(AtomicUsize::new(0));
let mut last_active = Instant::now();
loop {
match listener.accept() {
Ok((mut stream, _)) => {
last_active = Instant::now();
let Some(permit) = limiter.try_acquire() else {
let _ = configure_stream(&stream);
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
continue;
};
// The listener is nonblocking; individual requests still use blocking socket I/O.
let _ = stream.set_nonblocking(false);
let h = h.clone();
let token = Arc::clone(&token);
let active = Arc::clone(&active);
active.fetch_add(1, Ordering::AcqRel);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
active.fetch_sub(1, Ordering::AcqRel);
});
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if active.load(Ordering::Acquire) != 0 {
last_active = Instant::now();
} else if last_active.elapsed() >= idle {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::ConnectionAborted) => {}
Err(e) => return Err(AutomationError::Io(e.to_string())),
}
}
Ok(())
}
#[cfg(test)]
@@ -724,6 +771,129 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_replace_smart_object_reads_through_the_read_root_and_never_outside_it() {
let base = std::env::temp_dir().join(format!("pc-doc-replace-so-{}", std::process::id()));
let root = base.join("workspace");
let outside = base.join("outside");
for dir in [&root, &outside] {
std::fs::create_dir_all(dir).unwrap();
}
let png = |w: u32, h: u32, v: u8| {
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, vec![v; (w * h * 3) as usize]).unwrap();
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()
};
std::fs::write(root.join("logo.png"), png(8, 6, 42)).unwrap();
std::fs::write(root.join("red.png"), png(8, 6, 200)).unwrap();
std::fs::write(outside.join("red.png"), png(8, 6, 200)).unwrap();
let absolute = outside.join("red.png").to_string_lossy().into_owned();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let layer = h.handle("doc.place", json!({"path": "logo.png"})).unwrap()["layer"].clone();
// The smart object's file name and the composite at its centre: what Replace Contents changes.
let contents = |h: &mut Headless| {
let inspected = h.handle("doc.inspect", json!({})).unwrap();
let layers = inspected["layers"].as_array().unwrap();
let so = layers.iter().find(|l| l["id"] == layer).unwrap_or_else(|| panic!("{inspected}"));
let name = so["smartSource"]["fileName"].as_str().unwrap_or_default().to_owned();
let rendered = h.handle("doc.render", json!({"maxSide": 0})).unwrap();
let png = base64::engine::general_purpose::STANDARD.decode(rendered["base64"].as_str().unwrap()).unwrap();
let img = photocraft_codecs::decode(&png).unwrap();
let (w, ht) = img.dimensions();
let data = img.as_u8().unwrap();
let channels = data.len() / (w * ht) as usize;
(name, data[((ht / 2 * w + w / 2) as usize) * channels])
};
assert_eq!(contents(&mut h), ("logo.png".to_owned(), 42), "the placed contents");
let replaced = h.handle("doc.replaceSmartObject", json!({"path": "red.png", "layer": layer})).unwrap();
assert_eq!(replaced["layer"], layer, "{replaced}");
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "the contents were replaced");
h.handle("engine.execute", json!({"command": "edit.undo"})).unwrap();
assert_eq!(contents(&mut h), ("logo.png".to_owned(), 42), "one undo step restores the old contents");
h.handle("engine.execute", json!({"command": "edit.redo"})).unwrap();
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "redo replaces them again");
// Outside the read root, a bad layer, a missing path or a file that is no image: refused, nothing changed.
for (params, reason) in [
(json!({"path": "../outside/red.png"}), "parent traversal"),
(json!({"path": absolute}), "must be inside the automation root"),
(json!({"path": "logo.png", "layer": 999_999}), "no such layer"),
(json!({}), "needs `path`"),
] {
let refused = h.handle("doc.replaceSmartObject", params).unwrap_err().to_string();
assert!(refused.contains(reason), "{refused}");
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "nothing changed: {refused}");
}
std::fs::write(root.join("junk.png"), b"not an image").unwrap();
assert!(h.handle("doc.replaceSmartObject", json!({"path": "junk.png", "layer": layer})).is_err());
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "a file that is no image changes nothing");
// The ambient command keeps its refusal.
let ambient = h.handle("engine.execute", json!({"command": "layer.smartObjects.replaceContents", "params": {"path": "red.png"}}));
assert!(ambient.unwrap_err().to_string().contains("ambient filesystem paths"));
// Trusted local keeps the ambient command.
let mut t = Headless::trusted_local();
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let tl = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap()["layer"].clone();
let trusted = t.handle("doc.replaceSmartObject", json!({"path": root.join("red.png").to_string_lossy(), "layer": tl})).unwrap();
assert_eq!(trusted["layer"], tl, "{trusted}");
drop(t);
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_layers_reports_skips_in_workspace_and_trusted_sessions() {
for trusted in [false, true] {
let base = std::env::temp_dir().join(format!("pc-export-skips-{}-{trusted}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mut h =
if trusted { Headless::trusted_local() } else { Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&base), Some(&base)).unwrap()) };
h.handle("doc.new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).unwrap();
let adjustment = h.handle("engine.execute", json!({"command": "layer.newAdjustmentLayer.levels"})).unwrap()["layer"].clone();
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"name": "Levels"}})).unwrap();
let hidden = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Hidden"}})).unwrap()["layer"].clone();
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"visible": false}})).unwrap();
// Exercise the actual JSON-lines serve envelope, not only direct dispatch.
let h = Mutex::new(h);
let out_dir = base.join("out").to_string_lossy().into_owned();
let request = json!({"id": 8491, "method": "doc.exportLayers", "params": {"dir": out_dir}}).to_string() + "\n";
let mut output = Vec::new();
serve_lines(&h, request.as_bytes(), &mut output).unwrap();
let reply: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(reply["id"], 8491);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"]["files"], json!([format!("{out_dir}/card_0001_Background.png")]));
if !trusted {
assert_eq!(reply["result"]["count"], 1);
}
assert_eq!(
reply["result"]["skipped"],
json!([
{"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"},
{"layer": adjustment, "name": "Levels", "reason": "adjustment layer has no pixels of its own"}
])
);
assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 1);
let mut h = h.into_inner().unwrap();
let all_dir = base.join("all").to_string_lossy().into_owned();
let all = h.export_layers(None, &all_dir, &json!({"visibleOnly": false})).unwrap();
assert_eq!(all["files"].as_array().unwrap().len(), 2);
assert_eq!(all["skipped"].as_array().unwrap().len(), 1);
// Workspace no-overwrite errors still fail and remove only newly created files.
if !trusted {
let collision = base.join("collision");
std::fs::create_dir_all(&collision).unwrap();
std::fs::write(collision.join("card_0002_Background.png"), b"keep").unwrap();
assert!(h.export_layers(None, &collision.to_string_lossy(), &json!({"visibleOnly": false})).is_err());
assert_eq!(std::fs::read_dir(&collision).unwrap().count(), 1);
assert_eq!(std::fs::read(collision.join("card_0002_Background.png")).unwrap(), b"keep");
}
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
}
#[test]
fn doc_export_layers_writes_only_beneath_the_write_root() {
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
@@ -800,7 +970,7 @@ mod tests {
}
#[test]
fn indexed_place_and_data_set_methods_keep_the_active_document() {
fn indexed_place_replace_and_data_set_methods_keep_the_active_document() {
let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
@@ -817,6 +987,9 @@ mod tests {
let steps = [
("doc.place", json!({"path": "logo.png", "index": 1}), true),
// Document 1's active layer is now the placed Smart Object.
("doc.replaceSmartObject", json!({"path": "logo.png", "index": 1}), true),
("doc.replaceSmartObject", json!({"path": "missing.png", "index": 1}), false),
("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true),
("doc.exportLayers", json!({"dir": "layers", "index": 1}), true),
("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true),
@@ -836,6 +1009,142 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_data_sets_judges_pixel_replacement_paths_like_apply() {
let base = std::env::temp_dir().join(format!("pc-doc-data-set-pixels-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let red = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(16)).unwrap();
let photo = base.join("photo.png");
std::fs::write(&photo, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
let layer = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {}})).unwrap()["layer"].clone();
h.handle(
"engine.execute",
json!({"command": "image.variables.define", "params": {"defs": [{"name": "photo", "layer": layer, "type": "pixelReplacement"}]}}),
)
.unwrap();
// Storing the path is allowed; reading it is what the gate judges.
h.handle(
"engine.execute",
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
{"name": "swap", "values": [{"variable": "photo", "kind": "pixels", "value": photo.to_string_lossy()}]},
]}}),
)
.unwrap();
let applied = h.handle("engine.execute", json!({"command": "image.applyDataSet", "params": {"name": "swap"}})).unwrap_err().to_string();
assert!(applied.contains("ambient filesystem paths"), "{applied}");
let sets = root.join("sets");
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap_err().to_string();
assert!(exported.contains("ambient filesystem paths"), "{exported}");
assert_eq!(std::fs::read_dir(&sets).map_or(0, Iterator::count), 0, "nothing of the refused batch is written");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn workspace_psd_saves_embed_no_file_a_linked_smart_object_names() {
use photocraft_engine::doc::{LayerContent, SmartSource};
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0001";
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
let base = std::env::temp_dir().join(format!("pc-psd-linked-embed-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.txt");
std::fs::write(&outside, MARKER).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
// A trusted export embeds the linked file, as before.
assert!(has_marker(&photocraft_io::export(&state.doc, "psd", &Default::default()).unwrap().bytes));
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap();
let saved = h.handle("doc.save", json!({"path": "out.psd"})).unwrap();
let warnings = saved["warnings"].to_string();
assert!(warnings.contains("written as pixels"), "{warnings}");
assert!(!has_marker(&std::fs::read(root.join("out.psd")).unwrap()), "the linked file outside the roots is not embedded");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn allowed_filters_do_not_rerender_a_smart_object_from_its_linked_file() {
use photocraft_engine::doc::{LayerContent, SmartSource};
let base = std::env::temp_dir().join(format!("pc-linked-rerender-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let red = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(64)).unwrap();
let outside = base.join("red.png");
std::fs::write(&outside, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
// A document whose white smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("p.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "p.pcraft"})).unwrap();
h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 0.5}})).unwrap();
let doc = &h.session.active().unwrap().doc;
let LayerContent::Smart(smart) = &doc.layers[0].content else { panic!("smart object") };
assert_eq!(smart.smart_filters.len(), 1, "the filter is still recorded");
assert_eq!(smart.cache.as_ref().unwrap().rgba(4, 4), [1.0, 1.0, 1.0, 1.0], "the linked file outside the roots is not read");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn workspace_layer_exports_keep_linked_smart_objects_as_pixels() {
use photocraft_engine::doc::{LayerContent, SmartSource};
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0002";
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
let base = std::env::temp_dir().join(format!("pc-export-layers-linked-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.txt");
std::fs::write(&outside, MARKER).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap();
let out = root.join("layers");
for format in ["psd", "psb"] {
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": format})).unwrap();
assert_eq!(exported["count"], 1, "{exported}");
}
let files: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(files.len(), 2, "{files:?}");
for file in files {
assert!(!has_marker(&std::fs::read(&file).unwrap()), "{} embeds no file outside the roots", file.display());
}
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn trusted_local_render_keeps_full_size_behavior() {
let mut h = Headless::trusted_local();
@@ -874,6 +1183,38 @@ mod tests {
assert!(h.handle("methods", Value::Null).is_ok());
}
#[test]
fn idle_tcp_daemon_releases_its_port_but_keeps_active_clients() {
use std::io::BufReader;
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = Arc::new(Mutex::new(Headless::new()));
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let result = serve_tcp_until_idle("127.0.0.1:0", h, TOKEN.to_owned(), move |addr| ready_tx.send(addr).unwrap(), Duration::from_millis(200));
done_tx.send(result).unwrap();
});
let addr = ready_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let mut client = std::net::TcpStream::connect(addr).unwrap();
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let mut reader = BufReader::new(client.try_clone().unwrap());
writeln!(client, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let auth: Value = serde_json::from_str(&line).unwrap();
assert_eq!(auth["result"]["authenticated"], true);
// The port stays owned well past the idle limit while a client is connected.
assert!(
matches!(done_rx.recv_timeout(Duration::from_millis(450)), Err(std::sync::mpsc::RecvTimeoutError::Timeout)),
"daemon exited with a live client"
);
drop(reader);
drop(client);
assert!(done_rx.recv_timeout(Duration::from_secs(3)).unwrap().is_ok(), "idle daemon did not exit cleanly");
server.join().unwrap();
assert!(TcpListener::bind(addr).is_ok(), "idle listener still owns its port");
}
#[test]
fn tcp_serves_loopback() {
use std::io::{BufReader, Write as _};
+75 -6
View File
@@ -71,6 +71,20 @@ pub struct PlaceParams {
pub center: Option<[f64; 2]>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReplaceSmartObjectParams {
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
pub path: String,
/// Headless mode only: document index from `session_list` (default: the active document).
/// Bridge mode rejects `index`.
#[serde(default)]
pub index: Option<usize>,
/// The Smart Object layer: an id or a name (default: the active layer).
#[serde(default)]
pub layer: Option<Value>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportLayersParams {
@@ -336,7 +350,8 @@ pub struct UiSetParams {
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). Other fields are an error.
/// over this channel). updatesOpen (bool, the Help › Check for Updates window). Other fields
/// are an error.
pub fields: Value,
}
@@ -412,6 +427,14 @@ fn strict_node(v: &mut Value, defs: &serde_json::Map<String, Value>, depth: usiz
}
}
/// Both the bare engine list and older bridge wrappers own their rows already.
fn into_command_rows(mut reply: Value) -> Vec<Value> {
match reply {
Value::Array(rows) => rows,
_ => reply.get_mut("commands").and_then(Value::as_array_mut).map(std::mem::take).unwrap_or_default(),
}
}
fn ok_json(v: &Value) -> CallToolResult {
let text = match json_bytes(v).and_then(|bytes| String::from_utf8(bytes).map_err(|error| AutomationError::Other(error.to_string()))) {
Ok(text) => text,
@@ -637,7 +660,33 @@ impl PhotocraftMcp {
}
#[tool(
description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite."
description = "Replace a Smart Object's contents with the file at `path` beneath the read root, keeping its transform and filters (the capability-scoped Replace Contents). `layer` is the Smart Object's id or name (default: the active layer)."
)]
async fn doc_replace_smart_object(&self, Parameters(p): Parameters<ReplaceSmartObjectParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.layer {
opts.insert("layer".into(), v);
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which reads the path itself like any command.
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("path".into(), Value::String(p.path.clone()));
}
let (path, index) = (p.path, p.index);
if let Some(r) = self.headless_op(move |h| h.replace_smart_object(index, &path, &opts)).await {
return to_result(r);
}
// The app's command runs on its active document: an `index` it cannot honour is refused
// rather than dropped, or another document's contents would be replaced.
if index.is_some() {
return Ok(fail("`index` is only supported in headless mode; in bridge mode select the document in the app first"));
}
self.run_command("layer.smartObjects.replaceContents".into(), bridge_params, true).await
}
#[tool(
description = "Export top-level layers as files (groups merged). Returns files and skipped: [{layer, name, reason}] for hidden layers (visible_only) and adjustments with no pixels of their own. Workspace exports resolve beneath the write root, also return count of written files, and reject existing files instead of overwriting them. Trusted-local/bridge exports use the engine command's file access policy."
)]
async fn doc_export_layers(&self, Parameters(p): Parameters<ExportLayersParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
@@ -827,10 +876,7 @@ impl PhotocraftMcp {
};
let needle = p.filter.map(|f| f.to_lowercase());
let enabled_only = p.enabled_only.unwrap_or(false);
let items: Vec<Value> = all
.as_array()
.cloned()
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
let items: Vec<Value> = into_command_rows(all)
.into_iter()
.filter(|c| {
needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))
@@ -1083,6 +1129,29 @@ pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Res
mod tests {
use super::*;
#[test]
fn command_rows_keep_bare_and_wrapped_replies_compatible() {
let rows = json!([{"id":"first","params":{"nested":[1,2]}}, {"id":"second","enabled":false}]);
assert_eq!(into_command_rows(rows.clone()), rows.as_array().unwrap().clone());
assert_eq!(into_command_rows(json!({"commands":rows,"other":"ignored"})), rows.as_array().unwrap().clone());
for reply in [json!(null), json!(false), json!(3), json!("invalid"), json!({}), json!({"commands":null}), json!({"commands":{}}), json!([])] {
assert!(into_command_rows(reply).is_empty());
}
}
#[test]
fn command_rows_reuse_the_owned_array_and_nested_strings() {
for wrapped in [false, true] {
let rows = json!([{"id":"first","params":"documentation".repeat(64)}]);
let array = rows.as_array().unwrap().as_ptr();
let text = rows[0]["params"].as_str().unwrap().as_ptr();
let reply = if wrapped { Value::Object([("commands".into(), rows)].into_iter().collect()) } else { rows };
let moved = into_command_rows(reply);
assert_eq!(moved.as_ptr(), array, "wrapped={wrapped}: move the original allocation");
assert_eq!(moved[0]["params"].as_str().unwrap().as_ptr(), text, "wrapped={wrapped}: retain nested allocations");
}
}
#[test]
fn strict_schema_inlines_refs_and_stops_on_recursive_definitions() {
// A definition that refers to itself: inlining stops at a depth instead of looping.
+10 -1
View File
@@ -398,12 +398,13 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
| "gradient.presets.importGrd"
| "plugin.install"
| "swatches.import"
| "style.presets.importAsl"
| "layer.layerStyle.contours.load" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
"actions.save" | "swatches.export" | "style.presets.exportAsl" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
@@ -845,6 +846,9 @@ mod tests {
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})),
("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})),
("style.presets.exportAsl", serde_json::json!({"path": 5})),
("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})),
("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})),
] {
@@ -859,6 +863,8 @@ mod tests {
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
("style.presets.importAsl", serde_json::json!({"data": "QUJD"})),
("style.presets.exportAsl", serde_json::json!({"group": "Basics"})),
("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})),
("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})),
] {
@@ -947,6 +953,7 @@ mod tests {
| "path.rename"
| "path.stroke"
| "type.attachToPath"
| "type.fillPath"
| "type.detachFromPath"
| "select.toWorkPath"
| "layer.vectorMask.add"
@@ -993,6 +1000,8 @@ mod tests {
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})),
("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})),
("prefs.set", serde_json::json!({"path": "historyLog.filePath", "value": "/outside/log"})),
("prefs.set", serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})),
] {
+88
View File
@@ -80,6 +80,7 @@ async fn lists_expected_tools() {
"doc_open",
"doc_new",
"doc_place",
"doc_replace_smart_object",
"doc_export_layers",
"doc_export_data_sets",
"doc_import_data_sets",
@@ -706,6 +707,36 @@ async fn doc_place_is_capability_scoped() {
cleanup(&base);
}
/// #8061: Replace Contents reads from the read root through `doc_replace_smart_object`; the
/// ambient `layer.smartObjects.replaceContents` stays refused.
#[tokio::test(flavor = "multi_thread")]
async fn doc_replace_smart_object_is_capability_scoped() {
let base = tmp("replace-so");
let root = base.join("workspace");
let outside = base.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 144]).unwrap();
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
std::fs::write(root.join("inside.png"), &bytes).unwrap();
std::fs::write(outside.join("outside.png"), bytes).unwrap();
let client = connect(headless_in(&root)).await;
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
let placed = json_of(&call(&client, "doc_place", json!({"path": "inside.png"})).await);
let layer = placed["layer"].clone();
let replaced = call(&client, "doc_replace_smart_object", json!({"path": "inside.png", "layer": layer})).await;
assert_ne!(replaced.is_error, Some(true), "{}", text(&replaced));
let refused = call(&client, "doc_replace_smart_object", json!({"path": outside.join("outside.png").to_string_lossy(), "layer": layer})).await;
assert_eq!(refused.is_error, Some(true));
assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused));
let ambient = call(&client, "command_run", json!({"id": "layer.smartObjects.replaceContents", "params": {"path": "inside.png", "layer": layer}})).await;
assert_eq!(ambient.is_error, Some(true));
assert!(text(&ambient).contains("ambient filesystem paths"), "{}", text(&ambient));
client.cancel().await.unwrap();
cleanup(&base);
}
/// #3216: the export half of #2810. The write root scopes both exports, the read root the CSV,
/// and the three ambient commands keep their refusal.
#[tokio::test(flavor = "multi_thread")]
@@ -768,6 +799,43 @@ async fn doc_exports_and_data_set_import_are_capability_scoped() {
cleanup(&base);
}
/// Skipped-layer details survive the MCP tool result, including the all-skipped case.
#[tokio::test(flavor = "multi_thread")]
async fn doc_export_layers_reports_skipped_layers() {
let base = tmp("skipped-layers");
let client = connect(headless_in(&base)).await;
json_of(&call(&client, "doc_new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).await);
let background = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"][0]["id"].clone();
let adjustment = json_of(&call(&client, "command_run", json!({"id": "layer.newAdjustmentLayer.levels"})).await)["layer"].clone();
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"name": "Levels/α"}})).await);
let hidden = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Hidden"}})).await)["layer"].clone();
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"visible": false}})).await);
let result = json_of(&call(&client, "doc_export_layers", json!({"dir": "layers"})).await);
assert_eq!(result["count"], 1);
assert_eq!(result["files"], json!(["layers/card_0001_Background.png"]));
assert_eq!(
result["skipped"],
json!([
{"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"},
{"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"}
])
);
let all = json_of(&call(&client, "doc_export_layers", json!({"dir": "all", "visible_only": false})).await);
assert_eq!(all["count"], 2);
assert_eq!(all["skipped"].as_array().unwrap().len(), 1);
assert_eq!(std::fs::read_dir(base.join("layers")).unwrap().count(), 1);
assert_eq!(std::fs::read_dir(base.join("all")).unwrap().count(), 2);
// Every top-level layer is still accounted for when none can be exported.
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"layer": background, "visible": false}})).await);
let none = json_of(&call(&client, "doc_export_layers", json!({"dir": "none"})).await);
assert_eq!(none["files"], json!([]));
assert_eq!(none["count"], 0);
assert_eq!(none["skipped"].as_array().unwrap().len(), 3);
assert_eq!(std::fs::read_dir(base.join("none")).unwrap().count(), 0);
client.cancel().await.unwrap();
cleanup(&base);
}
/// The template workflow stays in memory, including saving nested contents back to the parent.
#[tokio::test(flavor = "multi_thread")]
async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() {
@@ -1043,6 +1111,26 @@ async fn bridge_previews_reject_document_indices() {
assert_eq!(schema.get("additionalProperties"), Some(&json!(false)));
}
/// Bridge mode runs Replace Contents on the app's active document, so an `index` is refused
/// before anything reaches the app rather than dropped (it would replace another document's
/// contents); without one the command is forwarded as before.
#[tokio::test(flavor = "multi_thread")]
async fn bridge_replace_smart_object_rejects_a_document_index() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let refused = call(&client, "doc_replace_smart_object", json!({"path": "new.png", "index": 1})).await;
assert_eq!(refused.is_error, Some(true), "{}", text(&refused));
assert!(text(&refused).contains("index"), "{}", text(&refused));
let forwarded = call(&client, "doc_replace_smart_object", json!({"path": "new.png", "layer": 7})).await;
assert_ne!(forwarded.is_error, Some(true), "{}", text(&forwarded));
client.cancel().await.unwrap();
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), app).await.unwrap().unwrap();
let executed: Vec<&Value> = seen.iter().filter(|request| request["method"] == "engine.execute").collect();
assert_eq!(executed.len(), 1, "the indexed call must not reach the app: {seen:?}");
assert_eq!(executed[0]["params"]["command"], "layer.smartObjects.replaceContents");
assert_eq!(executed[0]["params"]["params"], json!({"path": "new.png", "layer": 7}));
}
#[tokio::test(flavor = "multi_thread")]
async fn headless_previews_preserve_document_indices() {
let client = connect(PhotocraftMcp::headless()).await;
+2 -1
View File
@@ -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
+24 -2
View File
@@ -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
+40 -1
View File
@@ -221,7 +221,14 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
};
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
let (entries, _) = checked_data_len(size)?;
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if hex.iter().any(|b| !b.is_ascii_hexdigit()) {
return err("invalid hexadecimal <data> in .look");
}
// Each float needs eight hex digits; as_chunks would silently discard a partial value.
if !hex.len().is_multiple_of(8) {
return err("incomplete float in .look <data>");
}
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
@@ -456,6 +463,38 @@ DOMAIN_MAX 0.5 1 1
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
}
#[test]
fn look_rgb_and_rgba_allow_whitespace_in_hex_data() {
let id = LutFile::identity(2);
for channels in [3, 4] {
let mut hex = String::new();
for rgb in id.data.as_chunks::<3>().0 {
for value in rgb.iter().copied().chain((channels == 4).then_some(1.0)) {
for byte in value.to_le_bytes() {
hex.push_str(&format!("{byte:02x} \t\r\n"));
}
}
}
let xml = format!("<look><size>2</size><data>\"{hex}\"</data></look>");
assert_eq!(parse("valid.look", xml.as_bytes()).unwrap().data, id.data);
}
}
#[test]
fn look_rejects_partial_floats_and_invalid_hex() {
let id = LutFile::identity(2);
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
for suffix in ["0", "00", "0000", "000000", "G", "é"] {
let xml = format!("<look><size>2</size><data>{hex}{suffix}</data></look>");
assert!(parse("bad.look", xml.as_bytes()).is_err(), "accepted malformed suffix {suffix:?}");
}
let invalid = hex.replacen('0', "G", 1);
let xml = format!("<look><size>2</size><data>{invalid}</data></look>");
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("invalid hexadecimal"));
let xml = format!("<look><size>2</size><data>{hex}00</data></look>");
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("incomplete float"));
}
#[test]
fn look_3d_size_exponent_overflow_is_rejected() {
let text = format!("<look><size>{}</size><data>00</data></look>", usize::MAX);
+102 -2
View File
@@ -184,6 +184,7 @@ pub struct Profile {
impl Profile {
/// Parses ICC profile bytes.
pub fn parse(bytes: &[u8]) -> Result<Profile, CmsError> {
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<Profile, CmsError> {
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<usize> = 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());
}
}
+18 -5
View File
@@ -703,20 +703,33 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms
type Key = (u64, u64, TransformOptions);
/// Process-wide cache of transforms keyed by profile content and options.
/// A cached transform with the ICC bytes of the profiles it was built from.
struct Entry {
src: Arc<Vec<u8>>,
dst: Arc<Vec<u8>>,
transform: Arc<Transform>,
}
/// 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<Arc<Transform>, CmsError> {
static CACHE: OnceLock<Mutex<HashMap<Key, Arc<Transform>>>> = OnceLock::new();
static CACHE: OnceLock<Mutex<HashMap<Key, Entry>>> = 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)
}
+3
View File
@@ -106,6 +106,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
`FidelityWarning::BitsReduced` reports a 10- or 12-bit save (and every Cineon save) that drops
precision: always for float samples, for U16 ones unless they are already code values at that
depth.
Other descriptors, signed or run-length-encoded data and other bit depths return
`CodecError::Unsupported` naming the field.
+20
View File
@@ -537,6 +537,26 @@ pub(crate) fn bits_for(opt: Option<DpxBits>, sample: SampleType) -> u8 {
}
}
/// The bits `format` stores below its U16 plan: DPX at 10 or 12 bits, Cineon's 10. `None` when
/// the stored depth is the plan's sample type.
pub(crate) fn packed_bits(format: Format, opts: &EncodeOptions) -> Option<u8> {
match format {
Format::Dpx => opts.dpx_bits.filter(|b| matches!(b, DpxBits::Bits10 | DpxBits::Bits12)).map(DpxBits::bits),
Format::Cineon => Some(10),
_ => None,
}
}
/// `true` when every sample of a U16 image is already a `bits`-bit code value, so writing it at
/// `bits` and reading it back gives the same samples (a frame opened from such a file).
pub(crate) fn keeps_u16_samples(img: &Image, bits: u8) -> bool {
if img.sample_type() != SampleType::U16 || !(1..16).contains(&bits) {
return false;
}
let max = (1u64 << bits) - 1;
img.data().as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).all(|v| widen(narrow(v, max), max) == v)
}
/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16.
pub(crate) fn plan_sample(opt: Option<DpxBits>, src: SampleType) -> SampleType {
match opt {
+74 -9
View File
@@ -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<Image, CodecError> {
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<Image, CodecError>
_ => (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() {
+24 -1
View File
@@ -72,8 +72,31 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
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 {
+35 -5
View File
@@ -85,7 +85,9 @@ pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result<Option<Vec<
if out.len() >= MAX_GIF_FRAMES {
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
}
if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
// Every frame keeps its own copy of the file's ICC profile along with its pixels.
let frame_bytes = (size as u64).saturating_add(icc.as_ref().map_or(0, |p| p.len() as u64));
if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc)));
}
let delay_ms = match dec.read_frame(&mut buf) {
@@ -126,9 +128,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
enc.set_icc_profile(icc.clone());
}
if opts.embed_metadata {
if let Some(exif) = &img.meta.exif {
// The pixels are written as they are shown: never let a viewer rotate them again.
enc.set_exif_metadata(crate::resolution::export_exif(exif, img.meta.dpi).into_owned());
if let Some(exif) = export_exif(&img) {
enc.set_exif_metadata(exif);
}
if let Some(xmp) = &img.meta.xmp {
enc.set_xmp_metadata(crate::resolution::export_xmp(xmp, img.meta.dpi).as_bytes().to_vec());
@@ -160,11 +161,40 @@ fn encode_lossy(img: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError
None
};
let icc = if opts.embed_icc { img.icc.as_deref() } else { None };
let exif = if opts.embed_metadata { img.meta.exif.as_deref().map(|e| crate::resolution::export_exif(e, img.meta.dpi)) } else { None };
let exif = if opts.embed_metadata { export_exif(img) } else { None };
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
Ok(riff(w, h, &frame, alpha.as_deref(), icc, exif.as_deref(), xmp.as_deref().map(str::as_bytes)))
}
/// The EXIF to write: the image's, upright (the pixels are written as they are shown, so a viewer
/// must never rotate them again), carrying the resolution. WebP has no resolution field of its own,
/// so the resolution goes into EXIF, added when the image has none (#2399).
pub(crate) fn export_exif(img: &Image) -> Option<Vec<u8>> {
let exif = img.meta.exif.as_deref().unwrap_or_default();
// A WebP without EXIF already reads as 72 ppi, so the default needs no block of its own (and
// the extended container it would bring to every web export).
if exif.is_empty() && img.meta.dpi.is_none_or(is_default_ppi) {
return None;
}
let out = crate::resolution::export_exif_adding_missing_resolution(exif, img.meta.dpi).into_owned();
(!out.is_empty()).then_some(out)
}
/// Whether a WebP of `img` keeps its resolution: the EXIF written carries it. Not when the image's
/// own EXIF can't be parsed: that block is written as it is rather than replaced, so the
/// resolution is dropped (and reported) instead of the metadata.
pub(crate) fn keeps_dpi(img: &Image) -> bool {
let bare_default = img.meta.exif.as_deref().is_none_or(<[u8]>::is_empty) && img.meta.dpi.is_none_or(is_default_ppi);
// The XMP written beside it carries the resolution too, and readers look there first.
let in_xmp = img.meta.xmp.as_deref().is_some_and(|x| crate::resolution::xmp_resolution(&crate::resolution::export_xmp(x, img.meta.dpi)).is_some());
bare_default || in_xmp || export_exif(img).is_some_and(|e| crate::resolution::exif_resolution(&e).is_some())
}
/// 72 ppi each way: what a file without a resolution opens as.
fn is_default_ppi((x, y): (f32, f32)) -> bool {
(x - 72.0).abs() < 0.01 && (y - 72.0).abs() < 0.01
}
/// A RIFF chunk: FourCC, little-endian size, payload, padded to an even length.
fn chunk(out: &mut Vec<u8>, fourcc: &[u8; 4], payload: &[u8]) {
out.extend_from_slice(fourcc);
+9 -4
View File
@@ -206,11 +206,16 @@ fn decode_lines(lines: &[Vec<u8>], limits: &Limits) -> Result<Image, CodecError>
}
}
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"))?;
+17 -3
View File
@@ -4,6 +4,7 @@
use std::fmt;
use crate::codecs::dpx;
use crate::format::{Format, caps};
use crate::image::{ChannelLayout, Image, SampleType};
use crate::options::{DdsCompression, EncodeOptions};
@@ -20,6 +21,12 @@ pub enum FidelityWarning {
from: SampleType,
to: SampleType,
},
/// Samples will be stored with fewer bits than their sample type holds: DPX at 10 or 12
/// bits, Cineon at 10.
BitsReduced {
from: SampleType,
bits: u8,
},
/// Float samples outside `[0, 1]` will be clipped by integer storage.
RangeClipped,
/// Non-opaque alpha will be discarded.
@@ -82,6 +89,7 @@ impl fmt::Display for FidelityWarning {
write!(f, "{} cannot be written in this build", format.name())
}
DepthReduced { from, to } => write!(f, "{} will be reduced to {}", depth_name(*from), depth_name(*to)),
BitsReduced { from, bits } => write!(f, "{} will be reduced to {bits}-bit", depth_name(*from)),
RangeClipped => write!(f, "HDR values outside 0..1 will be clipped"),
AlphaDiscarded => write!(f, "alpha will be discarded"),
AlphaBinarized => write!(f, "alpha will be reduced to on/off transparency"),
@@ -161,7 +169,7 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
// The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16).
Format::Dpx => Plan {
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
sample: dpx::plan_sample(opts.dpx_bits, sample),
},
// CIELab has no float form: float Lab is written with 16 bits.
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
@@ -201,7 +209,13 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
(SampleType::F16, SampleType::F32) => false,
_ => true,
};
if lossy_depth {
// DPX at 10 or 12 bits and Cineon keep fewer bits than their U16 plan: float samples always
// lose precision there, 16-bit ones unless they are already code values at that depth.
if let Some(bits) = dpx::packed_bits(format, opts).filter(|_| ss != SampleType::U8) {
if ss.is_float() || !dpx::keeps_u16_samples(&image.converted(p.layout, SampleType::U16), bits) {
w.push(W::BitsReduced { from: ss, bits });
}
} else if lossy_depth {
w.push(W::DepthReduced { from: ss, to: p.sample });
}
// FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too.
@@ -254,7 +268,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() });
}
}
if m.dpi.is_some() && !c.dpi {
if m.dpi.is_some() && (!c.dpi || (format == Format::WebP && !crate::codecs::webp::keeps_dpi(image))) {
w.push(W::DpiDropped);
}
// DPX keeps its own header fields, not free-form text.
+2 -1
View File
@@ -256,7 +256,8 @@ pub fn caps(format: Format) -> FormatCaps {
}
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
// Resolution travels in EXIF (WebP has no field of its own).
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, dpi: true, animation: true, ..base },
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
Format::Bmp => base,
Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base },
+3 -1
View File
@@ -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,
}
+43
View File
@@ -323,6 +323,49 @@ fn chosen_bit_depth_sets_the_header_and_packing() {
assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
}
/// The depth warnings of `img` written as `format` with `o`.
fn depth_warnings(img: &Image, format: Format, o: &EncodeOptions) -> Vec<FidelityWarning> {
fidelity_warnings_with(img, format, o)
.into_iter()
.filter(|w| matches!(w, FidelityWarning::DepthReduced { .. } | FidelityWarning::BitsReduced { .. }))
.collect()
}
#[test]
fn saving_sixteen_bits_at_ten_or_twelve_warns_with_the_stored_bits() {
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::<Vec<_>>()).unwrap();
for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] {
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits }]);
assert_eq!(FidelityWarning::BitsReduced { from: SampleType::U16, bits }.to_string(), format!("16-bit will be reduced to {bits}-bit"));
// Float samples name the stored bits too, not the 16-bit words they are packed in.
let float = img.convert(ChannelLayout::Rgb, SampleType::F32);
assert_eq!(depth_warnings(&float, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::F32, bits }]);
// 8-bit samples widen into 10 and 12 bits without loss.
let eight = img.convert(ChannelLayout::Rgb, SampleType::U8);
assert_eq!(depth_warnings(&eight, Format::Dpx, &opts(Some(choice))), []);
}
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits16))), []);
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(None)), []);
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits8))), [FidelityWarning::DepthReduced { from: SampleType::U16, to: SampleType::U8 }]);
assert_eq!(depth_warnings(&img, Format::Cineon, &EncodeOptions::default()), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]);
}
#[test]
fn a_frame_opened_at_ten_or_twelve_bits_saves_back_without_a_warning() {
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::<Vec<_>>()).unwrap();
for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] {
let opened = decode(&encode(&img, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap();
assert_eq!(depth_warnings(&opened, Format::Dpx, &opts(Some(choice))), [], "{bits}-bit");
assert_eq!(decode(&encode(&opened, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap(), opened, "{bits}-bit");
}
// A 12-bit frame saved at 10 bits does lose its two low bits.
let twelve = decode(&encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits12))).unwrap()).unwrap();
assert_eq!(depth_warnings(&twelve, Format::Dpx, &opts(Some(DpxBits::Bits10))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]);
let cineon = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap();
assert_eq!(depth_warnings(&cineon, Format::Cineon, &EncodeOptions::default()), []);
assert_eq!(depth_warnings(&cineon, Format::Dpx, &opts(Some(DpxBits::Bits10))), []);
}
#[test]
fn gray_with_alpha_is_written_as_luma_with_a_warning() {
let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap();
+15 -3
View File
@@ -99,13 +99,14 @@ fn exif_xmp_dpi_text_dropped_for_qoi() {
assert!(w.contains(&x), "{x:?}");
}
}
/// WebP keeps the resolution in EXIF (#2399); only free-form text has nowhere to go.
#[test]
fn webp_drops_dpi_and_text_only() {
fn webp_drops_text_only() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta =
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
let w = warns(&img, Format::WebP);
assert_eq!(w, vec![W::DpiDropped, W::TextDropped]);
assert_eq!(w, vec![W::TextDropped]);
}
#[test]
fn float_to_png_reduces_and_clips() {
@@ -180,6 +181,7 @@ fn every_warning_has_a_message() {
let all = [
W::WriteUnsupported { format: Format::Avif },
W::DepthReduced { from: SampleType::F32, to: SampleType::F16 },
W::BitsReduced { from: SampleType::U16, bits: 10 },
W::RangeClipped,
W::AlphaDiscarded,
W::AlphaBinarized,
@@ -209,9 +211,19 @@ fn warnings_match_actual_roundtrip_behaviour() {
let img = test_image(l, s);
let w = warns(&img, f);
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. }));
let depth_reduced = w.iter().any(|w| match w {
W::DepthReduced { .. } => true,
W::BitsReduced { from, .. } => from.is_float(),
_ => false,
});
let lost_depth = back.sample_type() != s && back.sample_type() != SampleType::F32 && s != SampleType::U8;
assert_eq!(depth_reduced, lost_depth, "{f:?} {l:?} {s:?} {w:?} -> {:?}", back.sample_type());
// Cineon stores 10 bits: 16-bit samples come back changed exactly when warned.
if f == Format::Cineon && s == SampleType::U16 && back.sample_type() == s {
let bits_reduced = w.iter().any(|w| matches!(w, W::BitsReduced { .. }));
let changed = back.to_u16_samples() != img.convert(back.layout(), s).to_u16_samples();
assert_eq!(bits_reduced, changed, "{f:?} {l:?} {w:?}");
}
let alpha_lost = l.has_alpha() && !back.layout().has_alpha();
assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}");
let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk();
+43
View File
@@ -38,6 +38,12 @@ fn check_format(format: Format) {
// TIFF writes the block's directories as its own and rebuilds a block from them on
// reading (tests/it/tiff_exif.rs); IFD0's orientation here is the image's.
assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
} else if c.exif && format == Format::WebP {
// WebP's resolution lives in EXIF, so the block comes back with resolution tags added.
let back_exif = back.meta.exif.as_deref().unwrap_or_else(|| panic!("{format:?} lost exif"));
assert_eq!(photocraft_codecs::resolution::exif_resolution(back_exif).map(|(x, y)| (x.round(), y.round())), Some((300.0, 150.0)));
// The rest of the block survives too: the Orientation tag (0x0112, little-endian).
assert!(back_exif.windows(2).any(|w| w == b"\x12\x01"), "{format:?} lost Orientation");
} else if c.exif {
assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif");
} else {
@@ -295,3 +301,40 @@ fn metadata_survives_depth_conversion() {
assert_eq!(back.icc, img.icc);
assert_eq!(back.meta.xmp, img.meta.xmp);
}
/// #2399: a WebP export keeps the resolution even when the image has no EXIF (WebP has no
/// resolution field of its own, so a minimal EXIF block carries it), lossless and lossy alike,
/// and no longer warns that it is dropped.
#[test]
fn webp_keeps_dpi_without_source_exif() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta = Metadata { dpi: Some((300.0, 150.0)), ..Default::default() };
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
for lossless in [true, false] {
let opts = EncodeOptions { webp_lossless: lossless, ..Default::default() };
let back = decode(&encode(&img, Format::WebP, &opts).unwrap()).unwrap();
let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("lossless {lossless}: dpi lost"));
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "lossless {lossless}: {x},{y}");
}
// EXIF that can't be parsed is written as it is, so the resolution is reported as dropped.
img.meta = Metadata { exif: Some(b"Exif\0\0garbage".to_vec()), dpi: Some((300.0, 150.0)), ..Default::default() };
assert!(fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
assert!(back.meta.exif.is_some());
// ...unless the XMP written beside it records the resolution, which readers look at first.
img.meta.xmp = Some(r#"<x:xmpmeta><rdf:Description tiff:XResolution="300/1" tiff:YResolution="150/1" tiff:ResolutionUnit="2"/></x:xmpmeta>"#.into());
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
let (x, y) = back.meta.dpi.expect("dpi from XMP");
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{x},{y}");
// The default 72 ppi needs no EXIF (a WebP without one opens at 72), so a plain export stays
// a simple-format file; nor does an image without a resolution.
img.meta = Metadata { dpi: Some((72.0, 72.0)), ..Default::default() };
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap();
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]));
// Without a resolution or EXIF there is still no EXIF chunk.
img.meta = Metadata::default();
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
assert!(back.meta.exif.is_none());
}
+19
View File
@@ -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]
+43 -3
View File
@@ -1,7 +1,7 @@
//! Photoshop and Paint.NET blend modes (scalar reference implementation).
//!
//! Formulas follow the public PDF / ISO 32000-2 and W3C Compositing definitions,
//! with Photoshop's variants where they differ (Soft Light, Hard Mix, Darker/Lighter Color,
//! with Photoshop's variants where they differ (Hard Mix, Darker/Lighter Color,
//! Linear Burn/Dodge, Vivid/Linear/Pin Light, Subtract, Divide). This is the CPU reference
//! that the GPU kernels must match.
@@ -323,10 +323,19 @@ fn hard_light(cb: f32, cs: f32) -> f32 {
}
}
/// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half).
/// Photoshop's Soft Light: the W3C/PDF curve. In the upper half (`cs > 0.5`) the backdrop moves
/// towards `D(cb)`, which is `sqrt(cb)` above a quarter and the cubic `((16·cb − 12)·cb + 4)·cb`
/// below it; with `sqrt` throughout, dark backdrops came out up to 20 levels too light (#8804,
/// measured on psd-tools `blend-modes/rgb-blend-modes.psd`).
#[inline]
fn soft_light_ps(cb: f32, cs: f32) -> f32 {
if cs <= 0.5 { 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) } else { 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) }
if cs <= 0.5 {
2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs)
} else {
let b = cb.max(0.0);
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
2.0 * cb * (1.0 - cs) + d * (2.0 * cs - 1.0)
}
}
// ---- non-separable helpers (W3C Compositing Level 1) ----
@@ -572,6 +581,37 @@ mod tests {
assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0));
}
/// #8804: Photoshop's Soft Light over a dark backdrop (cb ≤ 0.25, cs > 0.5) follows the W3C
/// curve `D(cb) = ((16·cb − 12)·cb + 4)·cb`, not `sqrt(cb)`. Measured on psd-tools
/// `blend-modes/rgb-blend-modes.psd` (Photoshop's merged image), in 8-bit levels:
/// (backdrop, source, Photoshop).
#[test]
fn soft_light_follows_photoshop_over_dark_backdrops() {
for (cb, cs, ps) in [(4u8, 247u8, 11.0f32), (6, 254, 19.0), (9, 252, 25.0), (10, 237, 32.0), (14, 253, 46.0)] {
let got = blend_channel(BlendMode::SoftLight, f32::from(cb) / 255.0, f32::from(cs) / 255.0) * 255.0;
assert!((got - ps).abs() <= 7.0, "cb {cb} cs {cs}: {got} vs Photoshop {ps}");
}
}
#[test]
fn soft_light_is_the_w3c_formula() {
let d = |cb: f32| if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.sqrt() };
let w3c = |cb: f32, cs: f32| if cs <= 0.5 { cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb) } else { cb + (2.0 * cs - 1.0) * (d(cb) - cb) };
for b in (0..=255u8).step_by(3) {
for s in (0..=255u8).step_by(5) {
let (cb, cs) = (f32::from(b) / 255.0, f32::from(s) / 255.0);
let got = blend_channel(BlendMode::SoftLight, cb, cs);
assert!((got - w3c(cb, cs)).abs() < 1e-5, "cb {b} cs {s}: {got} vs {}", w3c(cb, cs));
}
}
// Continuous where the two halves of D meet (both give 0.5 at cb = 0.25).
let below = blend_channel(BlendMode::SoftLight, 0.25, 1.0);
let above = blend_channel(BlendMode::SoftLight, 0.25 + 1e-6, 1.0);
assert!(close(below, 0.5) && (above - below).abs() < 1e-4, "{below} {above}");
// A negative backdrop (32-bit documents) counts as black under a light source, as before.
assert!(close(blend_channel(BlendMode::SoftLight, -0.1, 1.0), 0.0));
}
#[test]
fn vivid_light_source_extremes_and_interior_values() {
for cb in [0.0, 0.25, 0.5, 0.75, 1.0] {
+1
View File
@@ -21,6 +21,7 @@ rayon = { workspace = true }
proptest = { workspace = true }
criterion = { workspace = true }
photocraft-paint = { workspace = true }
photocraft-cms = { workspace = true }
[[bench]]
name = "compose"
@@ -0,0 +1,9 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] }
cc e14b02709a87770da2637c28678c4fa761b5628f402e0c6d60fe66f1aa4d4bfa # shrinks to s = Spec { w: 3, h: 2, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Group { children: [Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }), blend: Dissolve, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.8972109, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
cc 4eeba1f8db987228717b932b82d043d61e82fd897a33ea9e2ce68d6c61d7f491 # shrinks to s = Spec { w: 3, h: 5, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 0.026626235, clipped: false, visible: true, mask: None }, Pixels { kind: Adjust(Invert), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
+67 -9
View File
@@ -77,6 +77,12 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
/// unrounded). Integer depths work on whole levels like Photoshop's (see [`levels_q`] and
/// `adjustment_quantum`), 32-bit documents get its float Levels (see [`levels_float`]).
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>) {
apply_depth_mode(adj, buf, transfer, depth, ColorMode::Rgb);
}
/// [`apply_depth`] in a document of colour `mode` (which decides whether float curves extend,
/// see [`float_tone_extrapolates`]).
fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {
match adj {
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]),
@@ -143,7 +149,7 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
return;
}
let luts = tone_luts_depth(adj, depth);
apply_tone(buf, *space, &luts, identity);
apply_tone(buf, *space, &luts, identity, float_tone_extrapolates(adj, depth, mode));
}
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges));
@@ -198,8 +204,11 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
}
Adjustment::BlackWhite { weights, tint } => {
let hdr = depth == Some(SampleType::F32);
// 32-bit greys keep their superwhites: Photoshop 27.11 maps negatives to 0 and caps
// neutrals at 16, but leaves 2.0 at 2.0 (#8537).
let hi = if hdr { 16.0 } else { 1.0 };
map_rgb(buf, |c| {
let g = black_white_gray(c, weights);
let g = black_white_gray(c, weights).clamp(0.0, hi);
match tint {
// Photoshop tints like the Color blend mode: the tint's hue and saturation at
// the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In
@@ -335,7 +344,7 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O
});
}
} else {
apply_depth(adj, buf, transfer, depth);
apply_depth_mode(adj, buf, transfer, depth, mode);
}
}
@@ -563,8 +572,8 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
}
}
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain.
/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle.
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain; edited
/// ones extend past it (see [`float_tone_extrapolates`]).
pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool; 3] {
match adj {
Adjustment::Curves { master, per_channel, space: ToneSpace::Rgb, .. } if depth == Some(SampleType::F32) => {
@@ -574,9 +583,18 @@ pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool
}
}
pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec<f32>; 4], identity: [bool; 3]) {
/// Whether an edited Curves adjustment continues past 0..1 instead of clipping: in a 32-bit RGB
/// document Photoshop extends each curve beyond its end points along the slope of its end
/// segments (#7157: `[[0,0],[255,128]]` maps -0.5 to -0.250980407 and 2 to 1.003921628). Only
/// in RGB documents: the curve's space is not the document's, and other modes keep the table.
pub fn float_tone_extrapolates(adj: &Adjustment, depth: Option<SampleType>, mode: ColorMode) -> bool {
matches!(adj, Adjustment::Curves { space: ToneSpace::Rgb, .. }) && depth == Some(SampleType::F32) && mode == ColorMode::Rgb
}
pub(crate) fn apply_tone(buf: &mut Buffer, space: ToneSpace, luts: &[Vec<f32>; 4], identity: [bool; 3], extrapolate: bool) {
let sample = if extrapolate { lut_extended } else { lut };
match space {
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })),
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { sample(&luts[i], c[i]) })),
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(space, luts, c)),
}
}
@@ -744,7 +762,8 @@ pub fn range_lightness(c: [f32; 3], l: f32) -> [f32; 3] {
/// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a
/// secondary-colour part (yellow, cyan or magenta: middle − smallest) and a primary part (red,
/// green or blue: largest − middle); the sliders (percent, Photoshop defaults 40 60 40 60 20 80 for
/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts.
/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts. Unclipped: the
/// document's depth decides the range.
pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 {
let (r, g, b) = (c[0], c[1], c[2]);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
@@ -764,7 +783,7 @@ pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 {
} else {
5 // red + blue = magenta
};
(min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0).clamp(0.0, 1.0)
min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0
}
/// Ordered-dither offset in -0.5..0.5 (4×4 Bayer matrix) for document pixel (x, y).
@@ -992,6 +1011,24 @@ fn lut(table: &[f32], v: f32) -> f32 {
table[i] * (1.0 - f) + table[j] * f
}
/// Table steps the end slopes of [`lut_extended`] are measured over: one step's difference near
/// 0.5 carries f32 rounding (2e-4 of the slope), while 16 steps are still only 0.4 % of the domain.
pub const TONE_SLOPE_SPAN: usize = 16;
/// [`lut`] continued past 0..1 along the slope of the table's end segments, as Photoshop applies
/// a curve to float samples ([`float_tone_extrapolates`]). Inside the domain it is [`lut`];
/// non-finite samples stay with it too, so an infinite one can't become NaN on a flat end.
#[inline]
fn lut_extended(table: &[f32], v: f32) -> f32 {
let n = table.len();
if !v.is_finite() || (0.0..=1.0).contains(&v) || n < 2 {
return lut(table, v);
}
let k = TONE_SLOPE_SPAN.min(n - 1);
let per_unit = (n - 1) as f32 / k as f32;
if v < 0.0 { table[0] + v * (table[k] - table[0]) * per_unit } else { table[n - 1] + (v - 1.0) * (table[n - 1] - table[n - 1 - k]) * per_unit }
}
/// Photo Filter's linear-light RGB matrix. Photoshop multiplies the pixel's D50 XYZ, relative to
/// white, by the filter colour's (mixed in by `density`): a fixed linear map, so it is folded into
/// one matrix. With `normalize_y` the map keeps luminance Y (32-bit Preserve Luminosity; 8/16-bit
@@ -1299,6 +1336,11 @@ pub fn levels_gamma(u: f32, g: f32) -> f32 {
return u.powf(c);
}
let b = (a * u1 - u1.powf(c)) / u1.powf(q);
// Just above gamma 1 the toe shrinks to nothing: u1^q underflows to 0 while u1 doesn't, and
// the power curve is the toe's limit there (otherwise b = inf and black turns NaN).
if !b.is_finite() {
return u.powf(c);
}
a * u - b * u.powf(q)
}
@@ -2197,6 +2239,22 @@ mod tone_tests {
assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d));
}
/// #8537: 32-bit superwhites stay above 1 (Photoshop 27.11: 2.0 stays 2.0, neutrals cap at
/// 16, negatives go to 0); 8-bit still clips at 1.
#[test]
fn black_white_keeps_32_bit_superwhites() {
let adj = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None };
let run = |depth, c: f32| {
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c, c, c, 1.0]] };
apply_depth(&adj, &mut b, Transfer::Srgb, Some(depth));
b.px[0][0]
};
assert_eq!(run(SampleType::F32, 2.0), 2.0);
assert_eq!(run(SampleType::F32, 20.0), 16.0);
assert_eq!(run(SampleType::F32, -0.5), 0.0);
assert_eq!(run(SampleType::U8, 2.0), 1.0);
}
/// Photoshop 27.11, measured: Black & White (default weights) with Tint over greys. The tint
/// is blended like the Color blend mode: its hue and saturation at the grey's luminosity.
#[test]
+5 -3
View File
@@ -270,10 +270,12 @@ fn interior_effects_blend_over_the_layer_mode_unless_grouped() {
fn interior_effects_blend_with_the_backdrop_through_a_see_through_normal_layer() {
// #6232: #04170F under a Normal layer at fill 0 with a yellow Soft Light colour overlay. The
// overlay soft-lights the dark green it sees through the layer (Photoshop's soft light of
// yellow: sqrt(b) for red and green, b² for blue), not transparency, which would leave plain
// yellow. A half-transparent part of the layer mixes it in at half strength.
// yellow: the W3C D(b) for red and green, which is the cubic ((16b − 12)b + 4)b below a
// quarter (#8804), and b² for blue), not transparency, which would leave plain yellow. A
// half-transparent part of the layer mixes it in at half strength.
let bg: [f32; 3] = [4.0 / 255.0, 23.0 / 255.0, 15.0 / 255.0];
let lit = [bg[0].sqrt(), bg[1].sqrt(), bg[2] * bg[2]];
let dark = |b: f32| ((16.0 * b - 12.0) * b + 4.0) * b;
let lit = [dark(bg[0]), dark(bg[1]), bg[2] * bg[2]];
let half = |c: usize| (bg[c] + lit[c]) / 2.0;
for depth in DEPTHS {
let mut d = doc(depth, false);
+716
View File
@@ -0,0 +1,716 @@
//! Mode-native CMYK compositing: a CMYK document's layers mixed on their ink values, so opaque
//! pixels keep their exact inks (K-only black stays K-only) instead of going through display RGB
//! and back through the profile.
//!
//! This path covers the layer stacks whose blending is per channel, so it means the same on ink
//! values as on display colour: every blend mode except the ones that mix channels (Hue,
//! Saturation, Color, Luminosity, Darker Color, Lighter Color), with opacity, fill opacity and
//! masks (pixel, type, shape and smart object layers through their rendered pixels, solid colour
//! fills, and fills carrying their rendered pixels), isolated and pass-through groups, and
//! clipping groups. Blend modes act on each ink's brightness (1 − ink), as the RGB compositor's
//! do on each channel, and Dissolve uses the same noise. Type layers mix their edges in the
//! compositor's text gamma, on brightness. Adjustment layers that are defined ink by ink (Invert,
//! Posterize, and Levels and Curves recorded on the CMYK channels) change the inks themselves,
//! rounded to the document's levels like the compositor's. [`supports`] says whether a document
//! stays on it; anything else (gradient and pattern fills without rendered pixels, effects, other
//! adjustments, channel-mixing blend modes, knockouts, channel restrictions, Blend If) is left to
//! the RGB compositor, whose result callers convert back to CMYK.
//!
//! Buffers hold straight-alpha `[C, M, Y, K, A]` in 0..1 ink (0 = no ink).
use std::sync::Arc;
use photocraft_color::blend::BlendMode;
use photocraft_color::convert::CmykSpace;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::adjust::ToneSpace;
use photocraft_doc::{Adjustment, Document, Knockout, Layer, LayerContent};
use photocraft_geom::Rect;
use super::{Ctx, RENDER_TILE, advanced, empty_in, mask_vals, pattern};
#[path = "cmyk_split.rs"]
pub mod split;
/// One straight-alpha CMYK pixel: C, M, Y, K (0..1 ink), alpha.
pub type Cmyka = [f32; 5];
/// Deepest group nesting this path composites (deeper documents use the RGB compositor).
pub const MAX_DEPTH: u32 = 64;
/// Straight-alpha CMYK + alpha float buffer covering a rectangle.
#[derive(Clone, Debug, PartialEq)]
pub struct CmykBuffer {
pub rect: Rect,
pub px: Vec<Cmyka>,
}
impl CmykBuffer {
pub fn transparent(rect: Rect) -> Self {
Self { rect, px: vec![[0.0; 5]; rect.width() as usize * rect.height() as usize] }
}
}
/// Whether [`render_cmyk`] composites `doc` natively (a CMYK document made only of layers this
/// path handles; invisible layers don't count).
pub fn supports(doc: &Document) -> bool {
doc.mode == ColorMode::Cmyk && stack_supported(&doc.layers, 0)
}
/// The sibling list grouped as the compositor groups it: each base with the clipped layers above it.
fn clip_groups(layers: &[Layer]) -> impl Iterator<Item = (&Layer, &[Layer])> {
let mut i = 0;
std::iter::from_fn(move || {
let base = layers.get(i)?;
let mut j = i + 1;
while layers.get(j).is_some_and(|l| l.clipped) && !base.clipped {
j += 1;
}
let clipped = layers.get(i + 1..j).unwrap_or(&[]);
i = j.max(i + 1);
Some((base, clipped))
})
}
fn stack_supported(layers: &[Layer], depth: u32) -> bool {
if depth > MAX_DEPTH {
return false;
}
clip_groups(layers).all(|(base, clipped)| !base.visible || layer_supported(base, clipped, depth))
}
/// What every natively composited layer needs, whatever its content.
fn plain(l: &Layer) -> bool {
l.opacity.is_finite()
&& l.fill_opacity.is_finite()
&& !super::effects::has_effects(l)
&& l.advanced.knockout == Knockout::None
&& l.excluded_channels == 0
&& l.blend_if.is_default()
}
/// A layer whose own pixels this path reads in CMYK: pixel layers, and type, shape and smart
/// object layers through their rendered pixels (rendered in the document's model, so a colour
/// given in CMYK is drawn with exactly those inks); solid colour fills, and fills carrying the
/// pixels they were rendered to. Gradient and pattern fills without those pixels aren't.
fn cmyk_pixels(l: &Layer) -> bool {
let cmyk = |s: &photocraft_raster::Surface| s.format().mode == ColorMode::Cmyk;
match &l.content {
LayerContent::Raster(s) => cmyk(s),
LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => l.surface().is_none_or(cmyk),
LayerContent::Fill(f) => match &l.fill_cache {
Some(c) if c.fill == *f => cmyk(&c.surface),
// Solid colours and gradients render natively in CMYK; patterns don't.
_ => !matches!(f, photocraft_doc::Fill::Pattern { .. }),
},
_ => false,
}
}
fn layer_supported(l: &Layer, clipped: &[Layer], depth: u32) -> bool {
if !plain(l) || advanced::clips_individually(l, clipped) {
return false;
}
let content = match &l.content {
// A stroked shape with clipped layers draws its stroke above them (the compositor's
// split path), which this path doesn't do.
LayerContent::Shape(sh) if sh.stroke.is_some() && clipped.iter().any(|c| c.visible) => false,
LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) | LayerContent::Fill(_) => {
cmyk_pixels(l) && separable(l.blend)
}
LayerContent::Group(g) => {
g.artboard.is_none()
&& (separable(l.blend) || l.blend == BlendMode::PassThrough)
// A pass-through group with clipped layers mixes them through its isolated rendering.
&& !(passes_through(l) && clipped.iter().any(|c| c.visible))
&& stack_supported(&g.children, depth + 1)
}
LayerContent::Adjustment(a) => ink_adjustment(a) && separable(l.blend),
};
content && clipped.iter().filter(|c| c.visible).all(|c| plain(c) && (cmyk_pixels(c) || adjusts_inks(c)) && separable(c.blend))
}
/// Adjustments whose CMYK result is defined ink by ink, applied here on ink values: Invert and
/// Posterize (each channel on its own), and Levels and Curves recorded on the CMYK channels.
fn ink_adjustment(a: &Adjustment) -> bool {
match a {
Adjustment::Invert | Adjustment::Posterize { .. } => true,
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => *space == ToneSpace::Cmyk,
_ => false,
}
}
fn adjusts_inks(l: &Layer) -> bool {
matches!(&l.content, LayerContent::Adjustment(a) if ink_adjustment(a))
}
/// A group whose children composite straight into the backdrop (the compositor's rule).
fn passes_through(l: &Layer) -> bool {
matches!(l.content, LayerContent::Group(_)) && l.blend == BlendMode::PassThrough && l.fill_opacity >= 1.0 && !super::effects::has_effects(l)
}
/// Normal compositing of straight-alpha `source` (alpha times `opacity`) over `backdrop`: the
/// compositor's Normal formula, on the four ink channels.
#[inline]
pub fn normal(backdrop: Cmyka, source: Cmyka, opacity: f32) -> Cmyka {
let ab = backdrop[4];
let as_ = source[4] * opacity;
if as_ <= 0.0 {
return backdrop;
}
let ao = as_ + ab * (1.0 - as_);
if ao <= 0.0 {
return [0.0; 5];
}
let kb = ab * (1.0 - as_);
let inv = 1.0 / ao;
// Over nothing, or where both sides agree, the result is exactly the source's ink (the mix
// would only add rounding, which modes that jump at 0 or 1 ink could magnify).
let c = |i: usize| if kb <= 0.0 || backdrop[i] == source[i] { source[i] } else { (kb * backdrop[i] + as_ * source[i]) * inv };
[c(0), c(1), c(2), c(3), ao]
}
/// Blend modes that work channel by channel, which this path applies to each ink (the others
/// mix the channels together: hue, saturation, colour, luminosity, darker and lighter colour).
pub fn separable(mode: BlendMode) -> bool {
!matches!(
mode,
BlendMode::PassThrough
| BlendMode::Hue
| BlendMode::Saturation
| BlendMode::Color
| BlendMode::Luminosity
| BlendMode::DarkerColor
| BlendMode::LighterColor
)
}
/// The blended inks of a separable `mode`: the compositor's blend function applied to each
/// channel's brightness (1 − ink), as CMYK channels are blended, so Multiply adds ink and Screen
/// takes it away.
pub fn blend_ink(mode: BlendMode, cb: [f32; 4], cs: [f32; 4]) -> [f32; 4] {
let b = |i: usize| 1.0 - cb[i];
let s = |i: usize| 1.0 - cs[i];
let cmy = super::psblend::blend_rgb(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)]);
let k = super::psblend::blend_rgb(mode, [b(3); 3], [s(3); 3]);
// A result that is one of the inputs (Normal, Darken, Lighten, ...) is that ink exactly.
let ink = |r: f32, i: usize| {
if r == s(i) {
cs[i]
} else if r == b(i) {
cb[i]
} else {
1.0 - r
}
};
[ink(cmy[0], 0), ink(cmy[1], 1), ink(cmy[2], 2), ink(k[0], 3)]
}
/// [`composite_ink`] for a layer with separate `opacity` and `fill`, as the RGB compositor's
/// `psblend::composite_fill` does on each channel: for the special eight below 100% Fill the
/// source is blended at full strength pulled toward the mode's neutral colour
/// (`psblend::blend_rgb_fill`, on each ink's brightness), with Opacity as its coverage over the
/// backdrop; over transparency Fill stays coverage.
pub fn composite_ink_fill(mode: BlendMode, backdrop: Cmyka, source: Cmyka, opacity: f32, fill: f32, gamma: f32) -> Cmyka {
let f = fill.clamp(0.0, 1.0);
if f >= 1.0 || !super::psblend::fill_is_special(mode) {
return composite_ink(mode, backdrop, source, opacity * f, gamma);
}
let ab = backdrop[4];
let over = source[4] * opacity;
let alone = over * f;
if over <= 0.0 {
return backdrop;
}
let ao = ab + (1.0 - ab) * alone;
if ao <= 0.0 {
return [0.0; 5];
}
let b = |i: usize| 1.0 - backdrop[i];
let s = |i: usize| 1.0 - source[i];
let cmy = super::psblend::blend_rgb_fill(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)], f);
let k = super::psblend::blend_rgb_fill(mode, [b(3); 3], [s(3); 3], f);
let bl = [cmy[0], cmy[1], cmy[2], k[0]];
let (wb, wbs, ws) = (ab * (1.0 - over) / ao, ab * over / ao, (1.0 - ab) * alone / ao);
// Mixed as brightness, in the type gamma when there is one.
let enc = |v: f32| if gamma == 1.0 { v } else { super::psblend::text_encode(v, gamma) };
let dec = |v: f32| if gamma == 1.0 { v } else { super::psblend::text_decode(v, gamma) };
let c = |i: usize| 1.0 - dec(wb * enc(b(i)) + wbs * enc(bl[i]) + ws * enc(s(i)));
[c(0), c(1), c(2), c(3), ao]
}
/// Straight-alpha `source` (alpha times `opacity`) composited onto `backdrop` in a separable
/// `mode` (the general compositing formula, on inks), coverage mixed in the type `gamma` (1 =
/// none). Normal is [`normal_gamma`].
pub fn composite_ink(mode: BlendMode, backdrop: Cmyka, source: Cmyka, opacity: f32, gamma: f32) -> Cmyka {
if mode == BlendMode::Normal {
return normal_gamma(backdrop, source, opacity, gamma);
}
let ab = backdrop[4];
let as_ = source[4] * opacity;
if as_ <= 0.0 {
return backdrop;
}
let ao = as_ + ab * (1.0 - as_);
if ao <= 0.0 {
return [0.0; 5];
}
let cb = [backdrop[0], backdrop[1], backdrop[2], backdrop[3]];
let cs = [source[0], source[1], source[2], source[3]];
let bl = blend_ink(mode, cb, cs);
let (wb, ws, wbs) = ((1.0 - as_) * ab / ao, (1.0 - ab) * as_ / ao, as_ * ab / ao);
let c = |i: usize| {
if gamma == 1.0 {
wb * cb[i] + ws * cs[i] + wbs * bl[i]
} else {
let g = |ink: f32| super::psblend::text_encode(1.0 - ink, gamma);
1.0 - super::psblend::text_decode(wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(bl[i]), gamma)
}
};
[c(0), c(1), c(2), c(3), ao]
}
/// A finite sample in 0..1 (non-finite samples, possible in 32-bit documents, count as 0).
#[inline]
fn clean(v: f32) -> f32 {
if v.is_finite() { v } else { 0.0 }
}
/// [`normal`] with coverage mixed in the type-layer `gamma` space (1 = plain [`normal`]), as
/// the compositor mixes type edges. The space is one of light, so inks are mixed as their
/// brightness (1 − ink); fully covered pixels are the source's inks exactly.
#[inline]
pub fn normal_gamma(backdrop: Cmyka, source: Cmyka, opacity: f32, gamma: f32) -> Cmyka {
if gamma == 1.0 {
return normal(backdrop, source, opacity);
}
let ab = backdrop[4];
let as_ = source[4] * opacity;
if as_ <= 0.0 {
return backdrop;
}
let ao = as_ + ab * (1.0 - as_);
if ao <= 0.0 {
return [0.0; 5];
}
// Normal: the blended colour is the source's, so its weight joins the source's.
let (wb, ws) = ((1.0 - as_) * ab / ao, as_ / ao);
let g = |ink: f32| super::psblend::text_encode(1.0 - ink, gamma);
// A covered pixel, or a channel both sides agree on, is exactly that ink: the round trip
// through the gamma space would only add rounding (K-only type would gain a trace of CMY).
let c = |i: usize| {
if wb <= 0.0 || backdrop[i] == source[i] { source[i] } else { 1.0 - super::psblend::text_decode(wb * g(backdrop[i]) + ws * g(source[i]), gamma) }
};
[c(0), c(1), c(2), c(3), ao]
}
/// A layer's own CMYK pixels over `rect` (see [`cmyk_pixels`]), masks applied.
fn layer_content(l: &Layer, rect: Rect, cx: &Ctx) -> CmykBuffer {
let mut out = CmykBuffer::transparent(rect);
let rendered;
let surface = match &l.content {
LayerContent::Fill(f) => match &l.fill_cache {
Some(c) if c.fill == *f => Some(&c.surface),
_ => {
// The compositor's own CMYK rendering of the fill, at the document's depth (a
// colour given in CMYK keeps its inks); a solid colour is one pixel, spread out.
let solid = matches!(f, photocraft_doc::Fill::Solid(_));
let at = if solid { Rect::new(0, 0, 1, 1) } else { rect };
rendered = super::gradient_fill::render_cmyk_fill(f, at, super::fill_frame(l, cx.canvas), cx.depth);
match &rendered {
Some(s) if solid => {
let p = s.pixel(0, 0);
let v = |i: usize| p.get(i).copied().map_or(0.0, clean);
out.px.fill([v(0), v(1), v(2), v(3), v(4)]);
None
}
r => r.as_ref(),
}
}
},
_ => l.surface(),
};
if let Some(s) = surface {
let ch = s.format().channels();
let alpha = s.format().alpha;
let mut v = Vec::new();
s.read_region_into(rect, &mut v);
for (o, p) in out.px.iter_mut().zip(v.chunks_exact(ch.max(1))) {
let a = if alpha { p.get(4).copied().map_or(0.0, clean) } else { 1.0 };
*o = [
p.first().copied().map_or(0.0, clean),
p.get(1).copied().map_or(0.0, clean),
p.get(2).copied().map_or(0.0, clean),
p.get(3).copied().map_or(0.0, clean),
a,
];
}
}
apply_mask(l, &mut out, cx);
out
}
fn apply_mask(l: &Layer, buf: &mut CmykBuffer, cx: &Ctx) {
if let Some(m) = mask_vals(l, buf.rect, cx) {
for (p, k) in buf.px.iter_mut().zip(&m) {
p[4] *= clean(*k).max(0.0);
}
}
}
/// Mask value `i` of `m` (1 where there is no mask or no value).
#[inline]
fn mask_at(m: &Option<Vec<f32>>, i: usize) -> f32 {
m.as_ref().and_then(|v| v.get(i)).copied().map_or(1.0, |k| clean(k).max(0.0))
}
/// `table` (samples evenly over 0..1) at `v`, interpolated like the compositor's adjustment LUTs.
fn lut_at(table: &[f32], v: f32) -> f32 {
let Some(last) = table.len().checked_sub(1) else { return v };
let x = clean(v).clamp(0.0, 1.0) * last as f32;
let i = (x.floor() as usize).min(last);
let f = x - i as f32;
let at = |k: usize| table.get(k).copied().unwrap_or(v);
at(i) * (1.0 - f) + at((i + 1).min(last)) * f
}
/// Applies an [`ink_adjustment`] to every covered pixel. Like the document's channels, the
/// adjustment acts on each ink's brightness (1 − ink); Levels and Curves work on the whole levels
/// of a `depth` document as on the RGB compositor.
fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) {
let luts = matches!(adj, Adjustment::Levels { .. } | Adjustment::Curves { .. }).then(|| super::adjust::tone_luts_depth(adj, Some(depth)));
let tone = |ch: usize, v: f32| match adj {
Adjustment::Invert => 1.0 - v,
Adjustment::Posterize { levels } => super::adjust::posterize(v, *levels),
_ => luts.as_ref().and_then(|t| t.get(ch)).map_or(v, |t| lut_at(t, v)),
};
for p in &mut buf.px {
if p[4] <= 0.0 {
continue;
}
for (ch, ink) in p.iter_mut().take(4).enumerate() {
// A channel the adjustment leaves alone keeps its ink exactly (1 − (1 − x) can differ).
let (v, r) = (1.0 - *ink, tone(ch, 1.0 - *ink));
if r != v {
*ink = 1.0 - r;
}
}
}
}
/// Mixes an adjustment's result back into `base` in the layer's blend mode, by its opacity and
/// mask; coverage is unchanged (the compositor's adjustment mix).
fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) {
let opacity = l.opacity * l.fill_opacity;
let rect = base.rect;
let w = (rect.width() as usize).max(1);
let mv = mask_vals(l, rect, cx);
for (i, (p, a)) in base.px.iter_mut().zip(&adjusted.px).enumerate() {
let k = opacity * mask_at(&mv, i);
if k <= 0.0 {
continue;
}
if l.blend == BlendMode::Dissolve {
// As on the RGB compositor (#3050): opacity and mask are the chance a pixel takes the
// adjustment fully, by the same noise.
let x = rect.x0.saturating_add((i % w) as i32);
let y = rect.y0.saturating_add((i / w) as i32);
if super::dissolve_noise(x, y) < k {
*p = [a[0], a[1], a[2], a[3], p[4]];
}
continue;
}
let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]);
for (v, b) in p.iter_mut().zip(bl) {
*v = if k >= 1.0 { b } else { *v + (b - *v) * k };
}
}
}
/// Rounds to the document's levels after an adjustment layer, as the compositor does (on the
/// channel brightness the document stores, and coverage). Also used by [`super::native`].
pub(crate) fn quantize_inks(px: &mut [Cmyka], q: f32) {
let round = |v: f32| (v * q + 0.5).floor() / q;
for p in px {
for v in p.iter_mut().take(4) {
// Whole levels of ink: exactly the stored values, rounding ties as on brightness.
*v = (q - ((1.0 - *v) * q + 0.5).floor()) / q;
}
p[4] = round(p[4]);
}
}
/// Clipped layers onto their base's own rendering: each blends as if the base were opaque and
/// the base keeps its alpha.
fn composite_clipped(clipped: &[Layer], base: &mut CmykBuffer, cx: &Ctx) {
for c in clipped.iter().filter(|c| c.visible) {
if let LayerContent::Adjustment(adj) = &c.content {
let mut adjusted = base.clone();
adjust_inks(adj, &mut adjusted, cx.depth);
mix_adjusted(c, base, &adjusted, cx);
continue;
}
let content = layer_content(c, base.rect, cx);
let opacity = c.opacity * c.fill_opacity;
let gamma = super::text_gamma(c);
let rect = base.rect;
let w = (rect.width() as usize).max(1);
for (i, (p, s)) in base.px.iter_mut().zip(&content.px).enumerate() {
let alpha = p[4];
if alpha <= 0.0 {
continue;
}
if c.blend == BlendMode::Dissolve {
// As on the RGB compositor (#3052): the base keeps its alpha and each pixel takes
// the clipped layer fully or not at all, by the same noise.
let x = rect.x0.saturating_add((i % w) as i32);
let y = rect.y0.saturating_add((i / w) as i32);
if super::dissolve_noise(x, y) < s[4] * opacity {
*p = [s[0], s[1], s[2], s[3], alpha];
}
continue;
}
let r = composite_ink(c.blend, [p[0], p[1], p[2], p[3], 1.0], *s, opacity, gamma);
*p = [r[0], r[1], r[2], r[3], alpha];
}
}
}
/// A layer's isolated rendering onto the backdrop in its blend mode. Dissolve shows each pixel
/// fully or not at all, by the compositor's noise (the same pixels as on the RGB compositor).
fn blend_into(backdrop: &mut CmykBuffer, src: &CmykBuffer, mode: BlendMode, opacity: f32, gamma: f32) {
let rect = backdrop.rect;
let w = (rect.width() as usize).max(1);
for (i, (b, s)) in backdrop.px.iter_mut().zip(&src.px).enumerate() {
if s[4] <= 0.0 {
continue;
}
if mode == BlendMode::Dissolve {
let x = rect.x0.saturating_add((i % w) as i32);
let y = rect.y0.saturating_add((i / w) as i32);
let shown = super::dissolve_noise(x, y) < s[4] * opacity;
*b = normal(*b, [s[0], s[1], s[2], s[3], if shown { 1.0 } else { 0.0 }], 1.0);
continue;
}
*b = composite_ink(mode, *b, *s, opacity, gamma);
}
}
fn composite_stack(layers: &[Layer], backdrop: &mut CmykBuffer, cx: &Ctx) {
for (base, clipped) in clip_groups(layers) {
if base.visible {
composite_layer(base, clipped, backdrop, cx);
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, super::adjustment_quantum(cx.depth)) {
quantize_inks(&mut backdrop.px, q);
}
}
}
}
fn composite_layer(l: &Layer, clipped: &[Layer], backdrop: &mut CmykBuffer, cx: &Ctx) {
let rect = backdrop.rect;
if empty_in(l, rect) {
return;
}
let opacity = l.opacity * l.fill_opacity;
if let LayerContent::Group(g) = &l.content {
if passes_through(l) {
let needs_mix = opacity < 1.0 || l.mask.is_some() || l.vector_mask.is_some();
let before = needs_mix.then(|| backdrop.clone());
composite_stack(&g.children, backdrop, cx);
if let Some(before) = &before {
// The compositor's pass-through mix: coverage mixes premultiplied colour.
let mv = mask_vals(l, rect, cx);
for (i, (p, a)) in backdrop.px.iter_mut().zip(&before.px).enumerate() {
let k = opacity * mask_at(&mv, i);
let b = *p;
let wa = a[4] * (1.0 - k);
let wb = b[4] * k;
let alpha = wa + wb;
*p = if alpha > 0.0 {
let c = |c: usize| (a[c] * wa + b[c] * wb) / alpha;
[c(0), c(1), c(2), c(3), alpha]
} else {
[0.0; 5]
};
}
}
return;
}
let mut content = CmykBuffer::transparent(rect);
composite_stack(&g.children, &mut content, cx);
apply_mask(l, &mut content, cx);
composite_clipped(clipped, &mut content, cx);
// As the compositor blends an isolated group: Fill apart from opacity for the special eight.
blend_into_fill(backdrop, &content, l, 1.0);
return;
}
// Adjustment layers transform the backdrop, then blend the result back in.
if let LayerContent::Adjustment(adj) = &l.content {
let mut adjusted = backdrop.clone();
adjust_inks(adj, &mut adjusted, cx.depth);
composite_clipped(clipped, &mut adjusted, cx);
mix_adjusted(l, backdrop, &adjusted, cx);
return;
}
let mut content = layer_content(l, rect, cx);
composite_clipped(clipped, &mut content, cx);
blend_into_fill(backdrop, &content, l, super::text_gamma(l));
}
/// A layer's own rendering onto the backdrop with its opacity and fill: for Photoshop's special
/// eight ([`super::psblend::fill_is_special`]) Fill below 100% is part of the blend
/// ([`composite_ink_fill`]), as on the RGB compositor; otherwise both are coverage.
fn blend_into_fill(backdrop: &mut CmykBuffer, src: &CmykBuffer, l: &Layer, gamma: f32) {
if l.fill_opacity >= 1.0 || !super::psblend::fill_is_special(l.blend) {
return blend_into(backdrop, src, l.blend, l.opacity * l.fill_opacity, gamma);
}
for (b, s) in backdrop.px.iter_mut().zip(&src.px) {
if s[4] > 0.0 {
*b = composite_ink_fill(l.blend, *b, *s, l.opacity, l.fill_opacity, gamma);
}
}
}
/// One tile on ink values. `space` is the document's CMYK profile ([`super::cmyk_space`]): a
/// colour given in RGB or Lab (a fill, say) becomes ink through it, and tiles run on worker
/// threads, which don't inherit the caller's scope.
fn render_tile(doc: &Document, rect: Rect, cx: &Ctx, space: Option<&Arc<CmykSpace>>) -> CmykBuffer {
let mut b = CmykBuffer::transparent(rect);
// Blends stay clipped to 0..1 whatever the depth: unclipped (HDR) blending would make
// brightness above 1, i.e. negative ink. Set explicitly, since a tile can run on a thread
// that is in the middle of an RGB composite of a 32-bit document.
photocraft_color::convert::with_cmyk_space(space, || super::in_blend_space(false, false, || composite_stack(&doc.layers, &mut b, cx)));
b
}
/// Whether [`render_cmyk`] composites `doc` on ink values: on this module's exact path
/// ([`supports`]) or, for layer effects and knockouts, ink by ink through the RGB compositor
/// ([`split::supports`]).
pub fn renders_inks(doc: &Document) -> bool {
supports(doc) || split::supports(doc)
}
/// The composite of `doc` over `rect` in CMYK + alpha, mixed on ink values; `None` unless
/// [`renders_inks`]. Tiles render in parallel on native targets (one at a time on wasm);
/// every tile is independent, so the result doesn't depend on the tiling or the thread count.
pub fn render_cmyk(doc: &Document, rect: Rect) -> Option<CmykBuffer> {
render_cmyk_tiled(doc, rect, RENDER_TILE)
}
/// [`render_cmyk`] as display RGB (straight alpha), each pixel's inks shown through the
/// document's CMYK profile as the RGB compositor reads CMYK pixels; `None` unless
/// [`renders_inks`]. What a canvas showing the ink composite would draw.
pub fn render_display(doc: &Document, rect: Rect) -> Option<super::Buffer> {
let inks = render_cmyk(doc, rect)?;
let space = super::cmyk_space(doc);
let show = |part: &[Cmyka]| -> Vec<[f32; 4]> {
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
part.iter()
.map(|p| {
let [r, g, b] = photocraft_color::convert::cmyk_to_rgb([p[0], p[1], p[2], p[3]]);
[r, g, b, p[4]]
})
.collect()
})
};
// Rows of tiles at a time, on every core (the profile is set per worker thread).
let chunk = (rect.width() as usize).max(1) * RENDER_TILE as usize;
#[cfg(not(target_arch = "wasm32"))]
let px: Vec<[f32; 4]> = {
use rayon::prelude::*;
inks.px.par_chunks(chunk).flat_map_iter(show).collect()
};
#[cfg(target_arch = "wasm32")]
let px: Vec<[f32; 4]> = inks.px.chunks(chunk).flat_map(show).collect();
Some(super::Buffer { rect, px })
}
/// [`render_cmyk`] with an explicit tile size (tests check tile independence).
pub fn render_cmyk_tiled(doc: &Document, rect: Rect, tile: i32) -> Option<CmykBuffer> {
if supports(doc) {
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
let cx = Ctx::for_doc(doc, &patterns);
let space = super::cmyk_space(doc);
return Some(render_with(rect, tile, |t| render_tile(doc, t, &cx, space.as_ref())));
}
if !split::supports(doc) {
return None;
}
let mut out = CmykBuffer::transparent(rect);
let done = split::render_bands(doc, rect, 0, tile, |band| {
place(&mut out, &band);
Ok::<(), ()>(())
});
done.ok().map(|()| out)
}
/// Copies `part` into `out` where their rectangles overlap (`part` lies within `out`).
fn place(out: &mut CmykBuffer, part: &CmykBuffer) {
let (rect, w) = (out.rect, out.rect.width() as usize);
let pw = part.rect.width() as usize;
for (row, src) in part.px.chunks_exact(pw.max(1)).enumerate() {
let o = ((part.rect.y0 - rect.y0) as usize + row) * w + (part.rect.x0 - rect.x0) as usize;
if let Some(dst) = out.px.get_mut(o..o + pw) {
dst.copy_from_slice(src);
}
}
}
/// `rect` rendered tile by tile by `render_tile`, tiles in parallel on native targets.
fn render_with(rect: Rect, tile: i32, render_tile: impl Fn(Rect) -> CmykBuffer + Sync) -> CmykBuffer {
let tile = tile.max(1);
let mut out = CmykBuffer::transparent(rect);
if rect.is_empty() {
return out;
}
let mut tiles = Vec::new();
let mut y = rect.y0;
while y < rect.y1 {
let y1 = y.saturating_add(tile).min(rect.y1);
let mut x = rect.x0;
while x < rect.x1 {
let x1 = x.saturating_add(tile).min(rect.x1);
tiles.push(Rect::new(x, y, x1, y1));
x = x1;
}
y = y1;
}
#[cfg(not(target_arch = "wasm32"))]
let parts: Vec<CmykBuffer> = {
use rayon::prelude::*;
tiles.par_iter().map(|t| render_tile(*t)).collect()
};
#[cfg(target_arch = "wasm32")]
let parts: Vec<CmykBuffer> = tiles.iter().map(|t| render_tile(*t)).collect();
for part in &parts {
place(&mut out, part);
}
out
}
/// [`render_cmyk`] in horizontal bands, top to bottom, handing each band to `sink` (so a huge
/// document is never held as one full-size float composite); `None` unless [`renders_inks`].
/// `band_rows` as in [`super::render_bands`].
pub fn render_cmyk_bands<E>(doc: &Document, rect: Rect, band_rows: i32, mut sink: impl FnMut(CmykBuffer) -> Result<(), E>) -> Option<Result<(), E>> {
if !supports(doc) {
return split::supports(doc).then(|| split::render_bands(doc, rect, band_rows, RENDER_TILE, sink));
}
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
let cx = Ctx::for_doc(doc, &patterns);
let space = super::cmyk_space(doc);
let rows = super::band_rows_for(rect.width(), band_rows);
let mut y = rect.y0;
while y < rect.y1 {
let y1 = y.saturating_add(rows).min(rect.y1);
if let Err(e) = sink(render_with(Rect::new(rect.x0, y, rect.x1, y1), RENDER_TILE, |t| render_tile(doc, t, &cx, space.as_ref()))) {
return Some(Err(e));
}
y = y1;
}
Some(Ok(()))
}
#[cfg(test)]
#[path = "cmyk_tests.rs"]
mod tests;
+377
View File
@@ -0,0 +1,377 @@
//! CMYK documents with layer effects (and stack structures the ink path leaves alone, such as
//! knockouts), composited ink by ink through the RGB compositor itself.
//!
//! Everything allowed here works channel by channel: per-channel blend modes, effects built from
//! coverage and painted with colours or colour gradients, the ink-by-ink adjustments, masks,
//! groups, clipping and knockouts. So the RGB compositor run on a stand-in document whose red,
//! green and blue hold the brightness (1 − ink) of cyan, magenta and yellow gives exactly those
//! inks' result, and a second run with black's brightness in all three gives black. Blends stay
//! clipped to 0..1 (unclipped blending would make negative ink), as on the ink path.
//!
//! 32-bit documents stay on the RGB compositor (32-bit CMYK isn't a print mode, and 32-bit Levels
//! use their own float formula); so do pattern paints, bevel textures, effect blend modes that mix
//! channels, Blend If and channel restrictions, which don't work channel by channel.
use photocraft_color::blend::BlendMode;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::ToneSpace;
use photocraft_doc::effects::{Effect, FxPaint};
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use super::{CmykBuffer, MAX_DEPTH, clean, cmyk_pixels, ink_adjustment, separable};
use crate::{Buffer, Ctx, advanced, pattern};
/// Which ink (0..=3: C, M, Y, K) a stand-in document carries in its red, green and blue.
pub(super) type Planes = [usize; 3];
const CMY: Planes = [0, 1, 2];
const K: Planes = [3, 3, 3];
/// Whether `doc` composites ink by ink on this path.
pub fn supports(doc: &Document) -> bool {
doc.mode == ColorMode::Cmyk && doc.depth != SampleType::F32 && stack_supported(&doc.layers, 0)
}
fn stack_supported(layers: &[Layer], depth: u32) -> bool {
depth <= MAX_DEPTH && layers.iter().filter(|l| l.visible).all(|l| layer_supported(l, depth))
}
fn layer_supported(l: &Layer, depth: u32) -> bool {
let plain = l.opacity.is_finite() && l.fill_opacity.is_finite() && l.excluded_channels == 0 && l.blend_if.is_default();
// Effects on an adjustment layer (which has no pixels of its own to shape them) stay on the RGB
// compositor.
let effects = !crate::effects::has_effects(l)
|| (!matches!(l.content, LayerContent::Adjustment(_)) && l.effects.items.iter().filter(|e| e.enabled()).all(effect_supported));
let content = match &l.content {
LayerContent::Group(g) => g.artboard.is_none() && (separable(l.blend) || l.blend == BlendMode::PassThrough) && stack_supported(&g.children, depth + 1),
LayerContent::Adjustment(a) => ink_adjustment(a) && separable(l.blend),
// A stroked shape can draw its stroke from the vector, in its own colour.
LayerContent::Shape(sh) if sh.stroke.is_some() => false,
_ => cmyk_pixels(l) && separable(l.blend),
};
// Effects and knockouts are laid out over the layer's bounds: they must be the same on the
// stand-ins (see [`stand_in_surface`]).
let shaped = crate::effects::has_effects(l) || l.advanced.knockout != photocraft_doc::Knockout::None;
plain && effects && content && (!shaped || transparent_outside(l))
}
/// Whether every surface `l` draws from has alpha and a transparent default pixel.
fn transparent_outside(l: &Layer) -> bool {
match &l.content {
LayerContent::Group(g) => g.children.iter().all(transparent_outside),
_ => l.surface().is_none_or(|s| s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0)),
}
}
fn effect_supported(e: &Effect) -> bool {
let paint = |p: &FxPaint| !matches!(p, FxPaint::Pattern { .. });
match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => separable(s.common.blend),
Effect::OuterGlow(g) | Effect::InnerGlow(g) => separable(g.common.blend) && paint(&g.paint),
Effect::Stroke(s) => separable(s.common.blend) && paint(&s.paint),
Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } => separable(common.blend),
Effect::Satin(s) => separable(s.common.blend),
Effect::BevelEmboss(b) => separable(b.highlight.blend) && separable(b.shadow.blend) && b.texture.is_none(),
Effect::PatternOverlay { .. } => false,
}
}
/// The brightness (1 − ink) of ink `i` of a CMYK pixel or colour (0 for a missing or non-finite
/// value: no ink).
fn brightness(inks: &[f32], i: usize) -> f32 {
1.0 - inks.get(i).copied().map_or(0.0, clean)
}
fn stand_in_color(c: &Color, planes: Planes) -> Color {
let ink = c.in_mode(ColorMode::Cmyk).c;
Color { mode: ColorMode::Rgb, c: [brightness(&ink, planes[0]), brightness(&ink, planes[1]), brightness(&ink, planes[2]), 0.0], alpha: c.alpha }
}
/// A CMYK surface as an RGB one (same depth and alpha) holding the planes' brightness.
fn stand_in_surface(s: &Surface, planes: Planes) -> Surface {
stand_in_surface_in(s, planes, crate::BAND_PIXELS)
}
/// [`stand_in_surface`], converting about `budget` pixels' worth of tiles at a time.
fn stand_in_surface_in(s: &Surface, planes: Planes, budget: u64) -> Surface {
let from = s.format();
if from.mode != ColorMode::Cmyk {
return s.clone();
}
let to = PixelFormat { mode: ColorMode::Rgb, sample: from.sample, alpha: from.alpha };
let n = from.channels().max(1);
let px = |p: &[f32], dst: &mut Vec<f32>| {
dst.extend(planes.iter().map(|&i| brightness(p, i)));
if from.alpha {
dst.push(p.get(4).copied().unwrap_or(0.0));
}
};
let source_default = s.default_pixel();
let mut default = Vec::new();
px(&source_default, &mut default);
let map = |src: &[f32], dst: &mut Vec<f32>| {
for p in src.chunks_exact(n) {
let start = dst.len();
px(p, dst);
// Layer bounds count the pixels that differ from the default, transparent ones too: a
// transparent pixel whose other inks differ keeps a colour of its own here (it never
// shows), so the stand-in's bounds are the document's.
if from.alpha
&& p.get(4) == Some(&0.0)
&& *p != *source_default
&& dst.get(start..) == Some(&default[..])
&& let Some(v) = dst.get_mut(start)
{
*v = if *v > 0.5 { 0.0 } else { 1.0 };
}
}
};
let mut out = Surface::with_default(to, &default);
let rects: Vec<Rect> = s.tiles().map(|(c, _)| c.rect()).collect();
let convert = |r: &Rect| {
let (mut src, mut dst) = (Vec::new(), Vec::new());
s.read_region_into(*r, &mut src);
map(&src, &mut dst);
(*r, dst)
};
// Converted a band's worth of tiles at a time, so the float samples in flight stay about
// one render band's, not the whole layer's.
let mut start = 0;
while start < rects.len() {
let mut end = start;
let mut pixels = 0u64;
while let Some(r) = rects.get(end)
&& (end == start || pixels < budget)
{
pixels = pixels.saturating_add(u64::from(r.width()) * u64::from(r.height()));
end += 1;
}
let batch = rects.get(start..end).unwrap_or_default();
#[cfg(not(target_arch = "wasm32"))]
let tiles: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
batch.par_iter().map(convert).collect()
};
#[cfg(target_arch = "wasm32")]
let tiles: Vec<(Rect, Vec<f32>)> = batch.iter().map(convert).collect();
for (r, data) in &tiles {
out.write_region(*r, data);
}
start = end;
}
out
}
/// A fill's colours as stand-ins. A solid colour is first stored at the document's depth, as the
/// compositor renders CMYK solid fills (`gradient_fill::render_cmyk_fill`).
fn stand_in_fill(f: &mut Fill, planes: Planes, depth: SampleType) {
match f {
Fill::Solid(c) => {
let one = Rect::new(0, 0, 1, 1);
let stored = crate::gradient_fill::render_cmyk_fill(&Fill::Solid(*c), one, one, depth).map(|s| s.pixel(0, 0));
let ink = stored.as_deref().map_or(c.in_mode(ColorMode::Cmyk).c, |p| std::array::from_fn(|i| p.get(i).copied().unwrap_or(0.0)));
let alpha = stored.as_deref().and_then(|p| p.get(4).copied()).unwrap_or(c.alpha);
*c = stand_in_color(&Color { mode: ColorMode::Cmyk, c: ink, alpha }, planes);
}
Fill::Gradient { stops, .. } => stops.iter_mut().for_each(|s| s.1 = stand_in_color(&s.1, planes)),
_ => {}
}
}
/// Levels and Curves on the CMYK channels as RGB records of the planes' inks (the composite
/// record stays the composite).
fn stand_in_adjustment(a: &mut Adjustment, planes: Planes) {
match a {
Adjustment::Levels { per_channel, space, black, .. } if *space == ToneSpace::Cmyk => {
let ink = |i: usize| if i == 3 { black.clone() } else { per_channel.get(i).cloned().unwrap_or_default() };
*per_channel = [ink(planes[0]), ink(planes[1]), ink(planes[2])];
*black = Default::default();
*space = ToneSpace::Rgb;
}
Adjustment::Curves { per_channel, space, black, .. } if *space == ToneSpace::Cmyk => {
let ink = |i: usize| if i == 3 { black.clone() } else { per_channel.get(i).cloned().unwrap_or_default() };
*per_channel = [ink(planes[0]), ink(planes[1]), ink(planes[2])];
*black = Vec::new();
*space = ToneSpace::Rgb;
}
_ => {}
}
}
fn stand_in_effect(e: &mut Effect, planes: Planes) {
let cc = |c: &mut Color| *c = stand_in_color(c, planes);
let paint = |p: &mut FxPaint| match p {
FxPaint::Color(c) => cc(c),
FxPaint::Gradient(g) => g.stops.iter_mut().for_each(|s| cc(&mut s.1)),
FxPaint::Pattern { .. } => {}
};
match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => cc(&mut s.color),
Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint(&mut g.paint),
Effect::Stroke(s) => paint(&mut s.paint),
Effect::ColorOverlay { color, .. } => cc(color),
Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)),
Effect::Satin(s) => cc(&mut s.color),
Effect::BevelEmboss(b) => {
cc(&mut b.highlight_color);
cc(&mut b.shadow_color);
}
Effect::PatternOverlay { .. } => {}
}
}
fn stand_in_layer(l: &mut Layer, planes: Planes, depth: SampleType, canvas: Rect) {
// The compositor never draws a hidden layer (nor what is clipped to it): no stand-in needed.
if !l.visible {
return;
}
let cache = |c: &mut Option<Surface>| {
if let Some(s) = c {
*s = stand_in_surface(s, planes);
}
};
let frame = crate::fill_frame(l, canvas);
// A fill covers the canvas and beyond, where effects near its edges reach.
let reach = canvas.inflate(crate::effects::margin(l).saturating_add(1));
match &mut l.content {
LayerContent::Raster(s) => *s = stand_in_surface(s, planes),
LayerContent::Text(t) => cache(&mut t.cache),
LayerContent::Shape(sh) => cache(&mut sh.cache),
LayerContent::Smart(sm) => cache(&mut sm.cache),
LayerContent::Fill(f) => {
// The cache stays valid: its fill is changed the same way.
if let Some(c) = l.fill_cache.as_mut().filter(|c| c.fill == *f) {
c.surface = stand_in_surface(&c.surface, planes);
stand_in_fill(&mut c.fill, planes, depth);
} else if matches!(f, Fill::Gradient { .. })
&& let Some(s) = crate::gradient_fill::render_cmyk_fill(f, reach, frame, depth)
{
// A gradient is the compositor's own CMYK ramp (an RGB ramp of the stand-in
// colours would interpolate differently), given as the fill's rendered pixels.
let mut fill = f.clone();
stand_in_fill(&mut fill, planes, depth);
l.fill_cache = Some(photocraft_doc::FillCache { fill, surface: stand_in_surface(&s, planes) });
}
stand_in_fill(f, planes, depth);
}
LayerContent::Adjustment(a) => stand_in_adjustment(a, planes),
LayerContent::Group(g) => g.children.iter_mut().for_each(|c| stand_in_layer(c, planes, depth, canvas)),
}
l.effects.items.iter_mut().for_each(|e| stand_in_effect(e, planes));
}
/// `doc` as an RGB document carrying the `planes` inks' brightness.
/// Colours given in RGB or Lab become ink through the document's own CMYK profile, as on the
/// exact ink path.
pub(super) fn stand_in(doc: &Document, planes: Planes) -> Document {
let mut d = doc.clone();
d.mode = ColorMode::Rgb;
d.icc_profile = None;
let space = crate::cmyk_space(doc);
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
d.layers.iter_mut().for_each(|l| stand_in_layer(l, planes, doc.depth, doc.bounds()));
});
d
}
/// A stand-in document rendered over `rect` by the RGB compositor, blends clipped to 0..1.
struct StandIn<'a> {
doc: &'a Document,
cx: Ctx<'a>,
}
impl<'a> StandIn<'a> {
/// Ready to render `rect` (effect maps built up front, as the RGB compositor does).
fn prepare(&self, rect: Rect) {
self.cx.set_fx_clip(rect);
crate::prepare_effects(&self.doc.layers, rect, &self.cx, |f| crate::in_blend_space(false, false, f));
}
fn tile(&self, rect: Rect) -> Buffer {
crate::in_blend_space(false, false, || {
let mut b = Buffer::transparent(rect);
crate::composite_stack_at(&self.doc.layers, &mut b, &self.cx, advanced::Scope::ROOT);
b
})
}
}
/// The CMY and K stand-ins' tiles as one CMYK tile.
fn combine(cmy: &Buffer, k: &Buffer) -> CmykBuffer {
let ink = |v: f32| if v.is_finite() { (1.0 - v).clamp(0.0, 1.0) } else { 0.0 };
let px = cmy
.px
.iter()
.zip(&k.px)
.map(|(p, q)| {
let alpha = clean(p[3]).clamp(0.0, 1.0);
if alpha <= 0.0 { [0.0; 5] } else { [ink(p[0]), ink(p[1]), ink(p[2]), ink(q[0]), alpha] }
})
.collect();
CmykBuffer { rect: cmy.rect, px }
}
/// Renders `doc` (which [`supports`]) band by band through the two stand-ins.
pub(super) fn render_bands<E>(doc: &Document, rect: Rect, band_rows: i32, tile: i32, mut sink: impl FnMut(CmykBuffer) -> Result<(), E>) -> Result<(), E> {
let (cmy, k) = (stand_in(doc, CMY), stand_in(doc, K));
let no_patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
let a = StandIn { doc: &cmy, cx: Ctx::for_doc(&cmy, &no_patterns) };
let b = StandIn { doc: &k, cx: Ctx::for_doc(&k, &no_patterns) };
let rows = crate::band_rows_for(rect.width(), band_rows);
let mut y = rect.y0;
while y < rect.y1 {
let y1 = y.saturating_add(rows).min(rect.y1);
let band = Rect::new(rect.x0, y, rect.x1, y1);
a.prepare(band);
b.prepare(band);
sink(super::render_with(band, tile, |t| combine(&a.tile(t), &b.tile(t))))?;
y = y1;
}
Ok(())
}
/// `doc`'s `planes` stand-in rendered over `rect` (tests compare plane orders).
#[cfg(test)]
pub(super) fn render_planes(doc: &Document, rect: Rect, planes: Planes) -> Buffer {
let d = stand_in(doc, planes);
let no_patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
let s = StandIn { doc: &d, cx: Ctx::for_doc(&d, &no_patterns) };
s.prepare(rect);
s.tile(rect)
}
#[cfg(test)]
mod batch_tests {
use super::*;
/// A surface over many tiles converts alike one tile at a time or all at once, and a hidden
/// layer is left unconverted (the compositor never draws it).
#[test]
fn stand_ins_convert_in_batches_and_skip_hidden_layers() {
let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true);
let mut s = Surface::with_default(fmt, &[0.0; 5]);
let size = photocraft_geom::TILE_SIZE * 3 + 7;
s.fill_rect(Rect::new(0, 0, size, size / 2), &[0.2, 0.4, 0.6, 0.1, 1.0]);
s.fill_rect(Rect::new(5, size / 3, size, size), &[0.0, 0.9, 0.0, 0.5, 0.5]);
assert!(s.tiles().count() > 4);
let bounds = Rect::new(0, 0, size, size);
let read = |s: &Surface| {
let mut v = Vec::new();
s.read_region_into(bounds, &mut v);
v
};
for planes in [CMY, K] {
let whole = stand_in_surface_in(&s, planes, u64::MAX);
assert_eq!(read(&stand_in_surface_in(&s, planes, 1)), read(&whole));
assert_eq!(read(&stand_in_surface(&s, planes)), read(&whole));
}
let mut d = Document::new("d", photocraft_geom::Size::new(4, 4), ColorMode::Cmyk, SampleType::U8);
let mut hidden = Layer::raster("hidden", fmt);
hidden.visible = false;
d.layers.extend([Layer::raster("shown", fmt), hidden]);
let st = stand_in(&d, CMY);
let mode = |l: &Layer| l.surface().map(|s| s.format().mode);
assert_eq!(st.layers.iter().map(mode).collect::<Vec<_>>(), [Some(ColorMode::Rgb), Some(ColorMode::Cmyk)]);
}
}
File diff suppressed because it is too large. Load diff
+5 -17
View File
@@ -2234,23 +2234,8 @@ pub(crate) fn composite_with_effects_prepared(
let mut share = vec![0.0f32; w * h];
for (i, sh) in share.iter_mut().enumerate() {
let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);
let k = if maps.outline {
band * outline_share(shape.v[i], lay.px[i][3])
} else if inside(shape.v[i]) {
if vector_shape {
0.0
} else {
// The stroke lies outside the layer's pixels. Beneath the layer it may show
// only through the part of the pixel the shape doesn't cover (1 - a); the
// layer composited on top covers c = a × fill, so the share beneath is
// (1 - a) / (1 - c). At 100 % fill that is 1 (the layer hides the rest, as
// before); at 0 % fill the interior stays clear (Fill 0 % + Outside stroke).
let c = lay.px[i][3].clamp(0.0, 1.0);
if c >= 1.0 { 1.0 } else { ((1.0 - shape.v[i].clamp(0.0, 1.0)) / (1.0 - c)).clamp(0.0, 1.0) }
}
} else {
band
};
let k =
if inside(shape.v[i]) { if vector_shape && !maps.outline { 0.0 } else { band * outline_share(shape.v[i], lay.px[i][3]) } } else { band };
let mut k = k * st.common.opacity;
if st.common.blend == BlendMode::Dissolve {
let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64);
@@ -2339,6 +2324,9 @@ pub(crate) fn composite_with_effects_prepared(
/// layer, `share + l (1 - share) = (1 - cov) + l`.
pub fn outline_share(cov: f32, l: f32) -> f32 {
let outside = (1.0 - cov.clamp(0.0, 1.0)).max(0.0);
if outside <= 1e-6 {
return 0.0;
}
let rest = 1.0 - l.clamp(0.0, 1.0);
if rest <= 1e-6 { 1.0 } else { (outside / rest).min(1.0) }
}
+82 -45
View File
@@ -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)] {
+93 -9
View File
@@ -19,6 +19,7 @@
pub mod adjust;
pub mod advanced;
pub mod bounds;
pub mod cmyk;
pub mod effects;
pub mod fill_layout;
pub mod filter_mask;
@@ -150,6 +151,22 @@ fn scratch_reuse_preserves_pixels_and_bounds_retention() {
});
}
/// Every layer the compositor draws, groups before their children: hidden layers and everything
/// inside hidden groups are left out.
pub(crate) fn visible_layers(layers: &[Layer]) -> Vec<&Layer> {
fn rec<'a>(layers: &'a [Layer], out: &mut Vec<&'a Layer>) {
for l in layers.iter().filter(|l| l.visible) {
out.push(l);
if let Some(children) = l.children() {
rec(children, out);
}
}
}
let mut out = Vec::new();
rec(layers, &mut out);
out
}
/// Composite the whole document over `rect`, in parallel 256² tiles on
/// native targets (single-threaded on wasm).
pub fn render(doc: &Document, rect: Rect) -> Buffer {
@@ -327,9 +344,12 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
/// leaves colour behind in CMYK (K = 100%) and Lab (neutral a/b = 0.5), and those pixels would
/// otherwise count as content: the result would span the whole canvas.
///
/// A CMYK document of plain Normal layers is composited in its ink values instead
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778),
/// and a Lab one in its L, a and b ([`native_lab::flatten_lab`]).
/// A CMYK document flattened to CMYK is composited in its ink values instead, so flattening
/// doesn't round-trip its pixels through RGB: plain Normal layers by [`native::flatten_cmyk`]
/// (#3778), and masks, groups, clipping, other blend modes and the other layer kinds by [`cmyk`]
/// where its layers mix on their inks (#2220). Otherwise the composite goes back to CMYK through
/// the document's own profile, the one its layers were read through. A Lab document of plain
/// layers is composited in its L, a and b ([`native_lab::flatten_lab`]).
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
composite_to_surface(doc, doc.bounds(), fmt, background)
}
@@ -351,9 +371,39 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
let n = fmt.channels();
let empty = s.default_pixel();
let mut data = Vec::new();
let space = cmyk_space(doc);
let to_cmyk = fmt.mode == photocraft_color::ColorMode::Cmyk && doc.mode == photocraft_color::ColorMode::Cmyk;
if to_cmyk && cmyk::renders_inks(doc) {
// White paper is no ink; another background colour goes through the profile.
let paper = background.map(|bg| {
if bg == [1.0; 3] { [0.0; 4] } else { photocraft_color::convert::with_cmyk_space(space.as_ref(), || photocraft_color::convert::rgb_to_cmyk(bg)) }
});
let _ = cmyk::render_cmyk_bands(doc, rect, 0, |band| -> Result<(), ()> {
data.clear();
data.reserve(band.px.len() * n);
for p in &band.px {
if paper.is_none() && fmt.alpha && (p[4] <= 0.0 || p[4].is_nan()) && empty.len() == n {
data.extend_from_slice(&empty);
continue;
}
// Over the paper (fully covered pixels keep their inks exactly), or straight.
let ink = |c: usize| match paper {
Some(bg) if p[4] < 1.0 => p[c] * p[4] + bg[c] * (1.0 - p[4]),
_ => p[c],
};
data.extend((0..4).map(ink));
if fmt.alpha {
data.push(if paper.is_some() { 1.0 } else { p[4] });
}
}
s.write_region(band.rect, &data);
Ok(())
});
s.prune();
return s;
}
// The composite separates back to CMYK through the document's own profile, the one its
// pixels were read through.
let cmyk = cmyk_space(doc);
let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> {
let band = match background {
Some(bg) => band.over_background(bg),
@@ -361,7 +411,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
};
data.clear();
data.resize(band.px.len() * n, 0.0);
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n {
out.copy_from_slice(&empty);
@@ -383,7 +433,11 @@ pub fn flatten(doc: &Document) -> Buffer {
}
/// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated.
/// Rects past [`MAX_RENDER_PIXELS`] give an empty buffer.
pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS {
return Buffer::transparent(Rect::EMPTY);
}
let mut buf = Buffer::transparent(rect);
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
composite_stack_at(
@@ -870,7 +924,8 @@ impl<'a> Ctx<'a> {
if identity == [true; 3] {
return;
}
adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity);
let extrapolate = adjust::float_tone_extrapolates(adj, Some(self.depth), self.mode);
adjust::apply_tone(buf, *space, luts.get_or_init(|| adjust::tone_luts_depth(adj, Some(self.depth))), identity, extrapolate);
} else {
adjust::apply_in(adj, buf, self.transfer, Some(self.depth), self.mode);
}
@@ -1720,14 +1775,34 @@ fn blend_if_level(x: f32) -> f32 {
if (v - r).abs() < 1e-4 { r } else { v }
}
/// Blend If groups inside one another whose own colour is still rendered separately. Each extra
/// level multiplies the work by the tree's depth, so the cost is polynomial, not exponential.
const MAX_BLEND_IF_GROUP_DEPTH: u32 = 2;
thread_local! {
/// Blend If groups being rendered for their own colour on this thread.
static BLEND_IF_GROUPS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn)
/// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer.
/// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since
/// every blend mode's source-over result is linear in the source alpha.
fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
// "This Layer" is the layer's own colour; adjustment layers (no content of their own) are
// judged by their result.
let own = if matches!(layer.content, LayerContent::Adjustment(_)) { None } else { render_content(layer, out.rect, cx) };
// judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend
// Ifs: a group's own colour re-composites its children, which doubles the work at every
// level of Blend If groups inside one another.
let is_group = matches!(layer.content, LayerContent::Group(_));
let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get);
let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) {
None
} else {
BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group)));
let own = render_content(layer, out.rect, cx);
BLEND_IF_GROUPS.with(|d| d.set(nested));
own
};
for (i, (p, b)) in out.px.iter_mut().zip(&before.px).enumerate() {
let this = match &own {
Some(o) => Some(o.px[i]).filter(|q| q[3] > 0.0),
@@ -2226,12 +2301,21 @@ fn fx_region_bytes(layers: &[Layer], canvas: Rect) -> u64 {
bytes(layers, canvas, 0)
}
/// Most bytes one layer's effect maps and source may take to build, whatever the cache budget.
const MAX_FX_BUILD_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 512 << 20 };
/// The layer's effect maps over its region (layer bounds grown by the effect reach, within the
/// canvas grown likewise; see [`effect_region`]), built once per layer state and shared by every
/// tile. `rect` is what the caller composites the layer over (see [`effect_region`]).
fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
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<bool> = 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) };
+13 -1
View File
@@ -189,7 +189,10 @@ impl<'a> InkStack<'a> {
match item {
Item::Layer(surf, opacity, mask) => {
let opacity = *opacity;
if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) {
// Nothing to draw only where the surface is transparent: without an alpha
// channel its default pixel is opaque (no ink is blank paper, not nothing).
let clear = surf.format().alpha && surf.default_pixel().last().is_some_and(|a| *a <= 0.0);
if opacity <= 0.0 || (clear && surf.content_bounds().intersect(&rect).is_empty()) {
continue;
}
let sf = surf.format();
@@ -211,7 +214,13 @@ impl<'a> InkStack<'a> {
}
}
Item::Adjust(op, opacity, mask) => {
// The result is rounded to the document's levels, as the compositor and the
// export path do after every adjustment layer, at any opacity (#9033).
let quantum = crate::adjustment_quantum(self.depth);
if *opacity <= 0.0 {
if let Some(q) = quantum {
crate::cmyk::quantize_inks(acc, q);
}
continue;
}
masked(*mask, mv);
@@ -225,6 +234,9 @@ impl<'a> InkStack<'a> {
d[c] += (v - d[c]) * mix;
}
}
if let Some(q) = quantum {
crate::cmyk::quantize_inks(acc, q);
}
}
}
}
+21 -1
View File
@@ -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] {
+31 -2
View File
@@ -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<TileCoord> = 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 {
+67
View File
@@ -92,6 +92,7 @@ fn outline_share_treats_stroke_and_shape_as_disjoint_areas() {
// Outside the shape the stroke covers the pixel; inside it nothing.
assert_eq!(effects::outline_share(0.0, 0.0), 1.0);
assert_eq!(effects::outline_share(1.0, 0.3), 0.0);
assert_eq!(effects::outline_share(1.0, 1.0), 0.0);
// Beneath an opaque edge pixel the result ends opaque: share + l (1 - share) = 1 - cov + l.
let (cov, l) = (0.6, 0.6);
let s = effects::outline_share(cov, l);
@@ -256,3 +257,69 @@ fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() {
assert!(close4(px(&d, 20, 20), [fill, fill, fill, 1.0]), "{sample:?} {:?}", px(&d, 20, 20));
}
}
#[test]
fn outside_stroke_on_raster_layer_does_not_paint_inside_opaque_pixels() {
let mut d = doc(40, 40);
let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8);
pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]);
let mut l = Layer::new("raster", LayerContent::Raster(pixels));
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())];
d.layers.push(l);
let center = px(&d, 20, 20);
assert!(close4(center, [1.0, 0.0, 0.0, 1.0]), "interior must stay red, got: {center:?}");
let stroke_pixel = px(&d, 13, 20);
assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue, got: {stroke_pixel:?}");
}
/// Beneath an opaque pixel the Outside stroke has no share, so a Multiply layer blended as a
/// group with its interior effects (where the layer's mode meets what lies beneath it inside the
/// style) multiplies the backdrop there, not the stroke colour (red × blue would be black), for a
/// raster layer and a filled shape alike.
#[test]
fn outside_stroke_under_multiply_layer_leaves_interior_to_backdrop() {
let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8);
pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]);
let layers = [("raster", Layer::new("raster", LayerContent::Raster(pixels))), ("shape", shape(15.0, 25.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), None))];
for (what, mut l) in layers {
let mut d = doc(40, 40);
l.blend = BlendMode::Multiply;
l.advanced.blend_interior = true;
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())];
d.layers.push(l);
let center = px(&d, 20, 20);
assert!(close4(center, [1.0, 0.0, 0.0, 1.0]), "{what}: interior must be red × white, got: {center:?}");
let stroke_pixel = px(&d, 13, 20);
assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "{what}: outside stroke must be blue, got: {stroke_pixel:?}");
}
}
#[test]
fn outside_stroke_on_zero_fill_raster_layer_keeps_interior_clear() {
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8);
pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]);
let mut l = Layer::new("raster", LayerContent::Raster(pixels));
l.fill_opacity = 0.0;
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())];
d.layers.push(l);
let center = px(&d, 20, 20);
assert_eq!(center[3], 0.0, "interior with 0% fill must stay clear: {center:?}");
let stroke_pixel = px(&d, 13, 20);
assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue: {stroke_pixel:?}");
}
#[test]
fn outside_stroke_on_partial_fill_raster_layer_keeps_interior_free_of_stroke() {
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
let mut pixels = photocraft_raster::Surface::new(PixelFormat::RGBA8);
pixels.fill_rect(Rect::new(15, 15, 25, 25), &[1.0, 0.0, 0.0, 1.0]);
let mut l = Layer::new("raster", LayerContent::Raster(pixels));
l.fill_opacity = 0.5;
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())];
d.layers.push(l);
let center = px(&d, 20, 20);
assert!(close4(center, [1.0, 0.0, 0.0, 0.5]), "interior must be 50% red: {center:?}");
let stroke_pixel = px(&d, 13, 20);
assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue: {stroke_pixel:?}");
}
+109 -4
View File
@@ -94,7 +94,7 @@ fn identity_float_curves_match_previous_mapping_bits() {
px: vec![[2.0, -0.0, -0.2, 1.0], [f32::from_bits(0x7fc01234), f32::INFINITY, f32::NEG_INFINITY, 0.6], [0.7, -0.0, 1.5, 0.0], [0.1; 4]],
};
let mut previous = input.clone();
adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3]);
adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3], false);
let mut actual = input.clone();
adjust::apply_depth(&adj, &mut actual, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
assert_buffer_bits(&actual, &previous);
@@ -1099,6 +1099,19 @@ fn cmyk_ink_compositing_inlines_open_groups() {
assert!(close4(px(&d, 3, 1), [c[0], c[1], c[2], 1.0]), "outside the group's layer");
}
/// A CMYK layer without an alpha channel is opaque everywhere, its untouched pixels blank paper
/// (no ink): the ink compositor keeps it under an adjustment rather than skipping it as empty.
#[test]
fn cmyk_ink_compositing_keeps_blank_paper_without_alpha() {
let mut d = Document::new("paper", Size::new(4, 4), ColorMode::Cmyk, SampleType::U8);
d.layers.push(Layer::raster("paper", PixelFormat::new(ColorMode::Cmyk, SampleType::U8, false)));
d.layers.push(Layer::new("t", LayerContent::Adjustment(Adjustment::Threshold { level: 0.5 })));
assert!(native::render_cmyk(&d, d.bounds(), 64).is_some());
// Paper is white, so Threshold keeps it paper.
let white = photocraft_color::convert::cmyk_to_rgb([0.0; 4]);
assert!(close4(px(&d, 2, 2), [white[0], white[1], white[2], 1.0]), "{:?}", px(&d, 2, 2));
}
#[test]
fn cmyk_document_renders_via_rgb() {
let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8);
@@ -1499,7 +1512,12 @@ fn glow_gradient_follows_the_shape() {
// new pixels (#4053). Effects that don't depend on the frame repaint only what changed.
#[test]
fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() {
let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false };
let overlay = Effect::GradientOverlay {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
gradient: Gradient::default(),
dither: false,
interpolation: None,
};
let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| {
let mut b = a.clone();
let old = b.layers.last().unwrap().clone();
@@ -1526,13 +1544,19 @@ fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() {
#[test]
fn gradient_overlay_follows_angle_and_reverse() {
let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() };
let d = fx_doc(vec![Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: g.clone(), dither: false }]);
let d = fx_doc(vec![Effect::GradientOverlay {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
gradient: g.clone(),
dither: false,
interpolation: None,
}]);
let (l, r) = (px(&d, 10, 20), px(&d, 29, 20));
assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}");
let d = fx_doc(vec![Effect::GradientOverlay {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
gradient: Gradient { reverse: true, style: GradientStyle::Linear, ..g },
dither: false,
interpolation: None,
}]);
assert!(px(&d, 10, 20)[0] > 0.9);
}
@@ -1737,6 +1761,20 @@ fn vector_mask_combines_with_pixel_mask() {
assert!(close4(px(&d, 6, 1), [1.0; 4]));
}
#[test]
fn effect_maps_over_distant_pixels_are_not_built_over_the_canvas() {
// Two pixels 190000 apart: the effect source region spans 3.6·10¹⁰ px.
let mut doc = Document::new("big", Size::new(200_000, 200_000), ColorMode::Rgb, SampleType::U8);
let mut l = solid_layer("fx", Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(190_000, 190_000, 190_001, 190_001), &[1.0, 0.0, 0.0, 1.0]);
l.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
doc.layers.push(l);
let t = std::time::Instant::now();
let px = render(&doc, Rect::new(0, 0, 4, 4));
assert_eq!(px.px.len(), 16);
assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
}
#[test]
fn effect_maps_are_cached_and_invalidated_by_pixel_changes() {
let mut doc = doc_white(64, 64);
@@ -2326,6 +2364,26 @@ fn blend_if_this_layer_hides_by_the_layers_own_value() {
assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0]));
}
#[test]
fn nested_blend_if_groups_do_not_double_the_work_at_every_level() {
// Each Blend If group re-renders its children for its own colour; 40 levels would be 2^40.
let mut inner = solid_layer("dot", Rect::new(0, 0, 4, 4), [1.0, 1.0, 1.0, 1.0]);
inner.blend_if.set(0, [range([10, 10], [255, 255]), FULL]);
let mut layers = vec![inner];
for _ in 0..40 {
let mut g = Layer::group("g", layers);
g.blend_if.set(0, [range([10, 10], [255, 255]), FULL]);
layers = vec![g];
}
// A black backdrop, not doc_white's white: the asserted white can then only come from the
// inner layer rendered through the whole group chain, not from the backdrop itself.
let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::BLACK);
d.layers.extend(layers);
let t = std::time::Instant::now();
assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]));
assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
}
#[test]
fn blend_if_underlying_layer_hides_by_the_backdrop_value() {
// Backdrop black on the left, white on the right; a red layer over all of it.
@@ -2700,7 +2758,7 @@ fn levels_steps_match_photoshop() {
// jump where it meets the power curve.
#[test]
fn levels_gamma_toe_is_monotone_for_any_gamma() {
for g in [1.0001, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] {
for g in [1.0001, 1.01, 1.0105422, 1.02, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] {
let mut last = 0.0f32;
for k in 0..=4096 {
let u = k as f32 / 4096.0;
@@ -2764,6 +2822,53 @@ fn exposure_scales_32_bit_samples_as_stored() {
}
}
/// #7157, measured on Photoshop 27.11: an edited Curves in a 32-bit RGB document continues past
/// 0..1 along its end segments instead of clipping. The straight curve `[[0,0],[255,128]]` maps
/// -0.5, 0.5 and 2 to -0.250980407, 0.250980407 and 1.003921628, through Image › Adjustments and
/// through an adjustment layer alike. 8/16-bit documents still clip to the table.
#[test]
fn float_curves_extend_past_the_unit_range_like_photoshop() {
use photocraft_doc::adjust::CurvePoint;
let line = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 128.0 / 255.0 }];
let ident = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
let adj = Adjustment::Curves { master: line, per_channel: std::array::from_fn(|_| ident.clone()), space: ToneSpace::Rgb, black: Vec::new(), pencil: false };
let input = Buffer { rect: Rect::new(0, 0, 3, 1), px: vec![[-0.5, -0.5, -0.5, 1.0], [0.5, 0.5, 0.5, 1.0], [2.0, 2.0, 2.0, 1.0]] };
// Photoshop's samples, compared in f64 so the reference keeps its printed digits.
let want = [-0.250980407f64, 0.250980407, 1.003921628];
let check = |got: &Buffer, what: &str| {
for (p, w) in got.px.iter().zip(want) {
assert!(p[..3].iter().all(|v| (f64::from(*v) - w).abs() < 2e-5), "{what}: {p:?} vs {w}");
assert_eq!(p[3], 1.0);
}
};
let mut destructive = input.clone();
adjust::apply_depth(&adj, &mut destructive, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
check(&destructive, "Image › Adjustments");
let doc = Document::new("hdr", Size::new(3, 1), ColorMode::Rgb, SampleType::F32);
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&doc, &patterns);
let mut layer = input.clone();
cx.apply_adjustment(&adj, &mut layer);
check(&layer, "adjustment layer");
// Not at 8 or 16 bits: those samples never leave 0..1, and the table's ends hold.
for depth in [SampleType::U8, SampleType::U16] {
let mut clipped = input.clone();
adjust::apply_depth(&adj, &mut clipped, adjust::Transfer::for_document(ColorMode::Rgb, depth), Some(depth));
assert!(clipped.px[2][..3].iter().all(|v| *v <= 128.0 / 255.0 + 1e-4), "{depth:?}: {:?}", clipped.px[2]);
}
// Only in RGB documents: a 32-bit grayscale document keeps the table's end.
let mut gray = input.clone();
adjust::apply_in(&adj, &mut gray, adjust::Transfer::Gamma(1.0), Some(SampleType::F32), ColorMode::Grayscale);
assert!(gray.px[2][..3].iter().all(|v| *v <= 128.0 / 255.0 + 1e-4), "grayscale: {:?}", gray.px[2]);
let mut rgb = input.clone();
adjust::apply_in(&adj, &mut rgb, adjust::Transfer::Gamma(1.0), Some(SampleType::F32), ColorMode::Rgb);
check(&rgb, "apply_in in RGB");
// Infinite samples don't become NaN on the way out.
let mut odd = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[f32::INFINITY, f32::NEG_INFINITY, 0.5, 1.0]] };
adjust::apply_depth(&adj, &mut odd, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
assert!(odd.px[0].iter().all(|v| !v.is_nan()), "{:?}", odd.px[0]);
}
#[test]
fn float_adjustments_preserve_hdr_range() {
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
+2 -1
View File
@@ -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;
+3 -2
View File
@@ -281,10 +281,11 @@ pub enum Adjustment {
relative: bool,
adjustments: [[f32; 4]; 9],
},
/// A PSD adjustment we can't evaluate yet; preserved raw for round-trip.
/// A PSD adjustment we can't evaluate yet; preserved raw for round-trip, shared between
/// document snapshots (#4535).
Unsupported {
psd_key: String,
raw: Vec<u8>,
raw: std::sync::Arc<[u8]>,
},
}
+18 -1
View File
@@ -37,6 +37,16 @@ pub struct Gradient {
pub offset: (f32, f32),
}
/// Editable colour stops behind a Gradient Overlay's baked render stops.
/// Older documents have no interpolation record and keep their existing appearance.
/// Writers must rebake `gradient.stops` from this record, or clear it when replacing the curve.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientInterpolation {
pub method: String,
pub smoothness: f32,
pub stops: Vec<(f32, Color)>,
}
impl Default for Gradient {
fn default() -> Self {
Gradient {
@@ -278,6 +288,8 @@ pub enum Effect {
common: FxCommon,
gradient: Gradient,
dither: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
interpolation: Option<GradientInterpolation>,
},
PatternOverlay {
common: FxCommon,
@@ -313,7 +325,12 @@ impl Effect {
Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint(&mut g.paint),
Effect::Stroke(s) => paint(&mut s.paint),
Effect::ColorOverlay { color, .. } => cc(color),
Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)),
Effect::GradientOverlay { gradient, interpolation, .. } => {
gradient.stops.iter_mut().for_each(|s| cc(&mut s.1));
if let Some(source) = interpolation {
source.stops.iter_mut().for_each(|s| cc(&mut s.1));
}
}
Effect::Satin(s) => cc(&mut s.color),
Effect::BevelEmboss(b) => {
cc(&mut b.highlight_color);
+4 -1
View File
@@ -846,7 +846,10 @@ pub type PsdGlobalBlock = ([u8; 4], [u8; 4], Arc<Vec<u8>>);
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Metadata {
pub xmp: Option<String>,
/// The XMP packet as stored, kept as text so properties PhotoCraft doesn't model survive.
/// Shared like the other payloads here: cloning a document or a history state never copies
/// it (#4535).
pub xmp: Option<Arc<str>>,
pub exif: Option<Arc<Vec<u8>>>,
/// Free-form image text (PNG tEXt/zTXt/iTXt, TIFF ASCII tags), including duplicate keys.
pub text: Vec<(String, String)>,
+2 -1
View File
@@ -57,8 +57,9 @@ impl DuotoneInk {
pub struct Duotone {
pub inks: Vec<DuotoneInk>,
/// The PSD colour-mode data this came from, written back unchanged while the inks are unedited.
/// Shared between document snapshots (#4535).
#[serde(default)]
pub psd_raw: Option<Vec<u8>>,
pub psd_raw: Option<std::sync::Arc<[u8]>>,
}
impl DuotoneInk {
+14 -4
View File
@@ -241,12 +241,22 @@ pub fn auto_slices(canvas: Rect, taken: &[Rect]) -> Vec<Rect> {
}
}
// 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<Rect> = 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
+6 -1
View File
@@ -222,7 +222,8 @@ pub struct ParagraphRun {
}
/// Point text grows from its anchor (text-space origin = first baseline at the anchor);
/// paragraph (box) text wraps inside a rectangle; path text runs along a curve.
/// paragraph (box) text wraps inside a rectangle; path text runs along a curve; area text
/// wraps inside a closed path.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub enum TextShape {
#[default]
@@ -234,6 +235,10 @@ pub enum TextShape {
/// curve. `start` is the arc-length offset (px) of the text origin along the path; `flip`
/// puts the text on the other side of the path.
Path { path: super::vector::Subpath, start: f32, flip: bool },
/// Area type: paragraph text that wraps inside the closed subpaths of `path` (text-space
/// pixels, before the layer transform), combined with their path operations and fill rule.
/// Text that doesn't fit is hidden, as in a text box.
Area { path: super::vector::Path },
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+4 -1
View File
@@ -38,7 +38,9 @@ craft-print = { workspace = true }
# Photoshop `.kys` shortcut sets (`edit.keyboardShortcuts` `importKys`); already in the tree
# through photocraft-ui-egui and usvg.
roxmltree = "0.20"
roxmltree = "0.21"
# Release tags use the same semantic versions as Cargo.
semver = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "3"
@@ -60,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.
+181
View File
@@ -100,6 +100,12 @@ pub struct ActionState {
/// Number of steps executed by the current top-level playback tree.
#[serde(skip)]
pub playback_steps: u64,
/// How many scripts (`file.scripts.browse`) are running inside one another.
#[serde(skip)]
pub script_depth: u8,
/// Steps run by the current top-level script and everything it calls.
#[serde(skip)]
pub script_steps: u64,
/// Bumped whenever `list` changes, so the preset store can skip unchanged writes.
#[serde(skip)]
pub rev: u64,
@@ -150,6 +156,14 @@ pub fn save_target_closed(s: &Session, id: &str, target: Option<photocraft_doc::
/// Charge one step against the complete nested playback tree.
pub fn consume_playback_step(s: &mut Session) -> bool {
// Inside a script the step also counts against the script's total: each top-level playback
// resets its own counter, so a script playing actions in a row would never reach the limit.
if s.actions.script_depth > 0 {
if s.actions.script_steps >= crate::automate_cmds::MAX_SCRIPT_STEPS {
return false;
}
s.actions.script_steps += 1;
}
if s.actions.playback_steps >= MAX_PLAYBACK_STEPS {
false
} else {
@@ -1167,6 +1181,173 @@ mod tests {
}
}
/// #7845: dragging a layer in the Layers panel while recording keeps the drop target by name,
/// so the action plays on another document (whose layer ids differ) instead of failing with
/// "no such layer". The reporter's steps, replayed on a new document.
#[test]
fn recorded_layer_drag_replays_on_another_document() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
s.execute("actions.record", json!({"name": "Retouch"})).unwrap();
s.execute("layer.stampVisible", json!({})).unwrap();
let layer1 = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
let layer2 = s.active().unwrap().active_layer.unwrap();
s.execute("layer.select", json!({"layer": layer1.0, "mode": "replace"})).unwrap();
s.execute("filter.noise.median", json!({"radius": 6})).unwrap();
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
let copy = s.active().unwrap().active_layer.unwrap();
s.execute("layer.moveTo", json!({"layer": copy.0, "target": layer2.0, "position": "above"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let (id, step) = s.actions.list[0].steps.last().unwrap();
assert_eq!((id.as_str(), step), ("layer.moveTo", &json!({"target": "Layer 1 copy", "position": "above"})));
let names = |s: &Session| s.active().unwrap().doc.layers.iter().rev().map(|l| l.name.clone()).collect::<Vec<_>>();
let recorded = names(&s);
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{depth}: {result}");
assert_eq!(names(&replay), recorded, "{depth}: the same stack");
}
// Several layers of the drop target's name: the recorded occurrence picks the right one.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
let upper = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Mover"})).unwrap();
let mover = s.active().unwrap().active_layer.unwrap();
s.execute("actions.record", json!({"name": "Drop"})).unwrap();
s.execute("layer.moveTo", json!({"layer": mover.0, "target": upper.0, "position": "below"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 1, "position": "below"}));
// The moved layer shares the target's name: the occurrence counts the other "Twin"s only,
// so it means the same layer before the move (on replay) as after it (when recorded).
let build = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
for _ in 0..3 {
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
}
s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>()
};
let mut s = Session::new();
let ids = build(&mut s);
let (lowest, top) = (ids[1], ids[3]);
s.execute("actions.record", json!({"name": "Twins"})).unwrap();
s.execute("layer.moveTo", json!({"layer": top.0, "target": lowest.0, "position": "below"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1, json!({"target": "Twin", "targetOccurrence": 0, "position": "below"}));
let order = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>();
let want: Vec<_> = order(&s).iter().map(|id| ids.iter().position(|x| x == id).unwrap()).collect();
let saved = serde_json::to_string(&s.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let ids2 = build(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
let got: Vec<_> = order(&replay).iter().map(|id| ids2.iter().position(|x| x == id).unwrap()).collect();
assert_eq!(got, want, "the same layer became the lowest");
// A moved group holding a same-named layer: its contents don't count either, or moving the
// group past another "Twin" would shift the occurrence between recording and replay.
let build_group = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let group = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
let top = s.active().unwrap().active_layer.unwrap();
s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap();
(group, top)
};
let mut g = Session::new();
let (group, top) = build_group(&mut g);
g.execute("actions.record", json!({"name": "Group"})).unwrap();
g.execute("layer.moveTo", json!({"layer": group.0, "target": top.0, "position": "above"})).unwrap();
g.execute("actions.stop", json!({})).unwrap();
let recorded = g.actions.list[0].steps[0].1.clone();
let saved = serde_json::to_string(&g.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let (group2, _) = build_group(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{recorded}: {result}");
let top_layer = |s: &Session| s.active().unwrap().doc.layers.last().map(|l| l.id);
assert_eq!(top_layer(&replay), Some(group2), "{recorded}: the group went to the top");
// Several moved layers named like the target are left out too, so the name is unambiguous.
let mut m = Session::new();
m.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let a = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let b = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let target = photocraft_doc::LayerId(m.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
m.execute("layer.moveTo", json!({"layers": [a.0, b.0], "target": "Twin", "position": "above"})).unwrap();
assert_eq!(m.active().unwrap().doc.layers.last().map(|l| l.id), Some(b), "both moved above the third Twin");
assert_eq!(m.active().unwrap().doc.layers.iter().rev().nth(2).map(|l| l.id), Some(target));
// An ⌥-drag copy of the selected layer beside itself replays by name too.
let mut c = Session::new();
c.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let src = photocraft_doc::LayerId(c.execute("layer.new.layer", json!({"name": "Solo"})).unwrap()["layer"].as_u64().unwrap());
c.execute("actions.record", json!({"name": "Copy"})).unwrap();
c.execute("layer.moveTo", json!({"layer": src.0, "target": src.0, "position": "above", "copy": true})).unwrap();
c.execute("actions.stop", json!({})).unwrap();
assert_eq!(c.actions.list[0].steps[0].1, json!({"target": "Solo", "position": "above", "copy": true}));
let saved = serde_json::to_string(&c.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
replay.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
replay.execute("layer.new.layer", json!({"name": "Solo"})).unwrap();
let before = replay.active().unwrap().doc.layers.len();
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert_eq!(replay.active().unwrap().doc.layers.len(), before + 1, "one copy");
// Copying a group holding a layer named like the target: the new copy's contents (made
// before the step is recorded) don't count, so the occurrence matches the replay.
let build_copy = |s: &mut Session| {
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let group = s.active().unwrap().active_layer.unwrap();
let a = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
let b = photocraft_doc::LayerId(s.execute("layer.new.layer", json!({"name": "Twin"})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.select", json!({"layer": group.0, "mode": "replace"})).unwrap();
(group, a, b)
};
let mut gc = Session::new();
let (group, a, _) = build_copy(&mut gc);
gc.execute("actions.record", json!({"name": "Copy group"})).unwrap();
// Below `a`: the copy's "Twin" then comes before `a` in document order.
gc.execute("layer.moveTo", json!({"layer": group.0, "target": a.0, "position": "below", "copy": true})).unwrap();
gc.execute("actions.stop", json!({})).unwrap();
let saved = serde_json::to_string(&gc.actions.list).unwrap();
let mut replay = Session::new();
replay.actions.list = serde_json::from_str(&saved).unwrap();
let (_, a2, _) = build_copy(&mut replay);
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
let layers = &replay.active().unwrap().doc.layers;
let at = layers.iter().position(|l| l.id == a2).unwrap();
let below = at.checked_sub(1).and_then(|i| layers.get(i)).map(|l| l.name.as_str());
assert_eq!(below, Some("Group 1 copy"), "the copy landed below the same Twin");
// A drag of several layers keeps its ids (not covered by name).
s.execute("actions.record", json!({"name": "Several"})).unwrap();
s.execute("layer.moveTo", json!({"layers": [ids[2].0, ids[3].0], "target": ids[1].0, "position": "above"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[1].steps[0].1["target"], json!(ids[1].0));
// Bad targets are errors, not panics.
for bad in [
json!({"target": "Nobody"}),
json!({"target": "Twin"}),
json!({"target": "Twin", "targetOccurrence": 9}),
json!({"target": "Twin", "targetOccurrence": -1}),
json!({"target": true}),
] {
assert!(s.execute("layer.moveTo", bad.clone()).is_err(), "{bad}");
}
}
/// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain
/// layer's recorded copy must still export to .atn, while an artboard's must be refused rather
/// than exported as a copy beside the original.
+1 -1
View File
@@ -355,7 +355,7 @@ pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option<
} else {
let mode = surf.format().mode;
let adj = crate::adjust_params::from_params(kind, p, None, mode).ok()?;
crate::pixels::adjust_surface(&mut out, &adj, None, mode);
crate::pixels::adjust_surface(&mut out, &adj, None, mode).ok()?;
}
out.prune();
Some(out)
+105 -5
View File
@@ -44,6 +44,14 @@ fn selected_pixel_layers(s: &Session) -> Vec<LayerId> {
st.selected_layers().into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Raster(_)))).collect()
}
/// Whether a lock keeps the layer `id` from being moved or warped: lock all, position or pixels.
/// The Background's own position lock doesn't count: like Free Transform, Auto-Align turns the
/// Background into a normal layer when it has to move it (Photoshop).
fn pinned(doc: &Document, id: LayerId) -> bool {
let l = doc.effective_locks(id);
l.all || l.pixels || (l.position && !doc.is_background(id))
}
fn two_layers(s: &Session) -> std::result::Result<(), String> {
s.active().ok_or("no document open")?;
if selected_pixel_layers(s).len() < 2 { Err("select two or more pixel layers in the Layers panel".into()) } else { Ok(()) }
@@ -63,7 +71,8 @@ fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
let reference = match p.get("reference").and_then(Value::as_u64) {
Some(r) if ids.contains(&LayerId(r)) => LayerId(r),
Some(r) => return Err(bad(cmd, format!("reference {r} is not a selected pixel layer"))),
None => ids[0],
// As in Photoshop, a locked layer stays put: it becomes the reference (#7080).
None => ids.iter().copied().find(|id| pinned(&doc, *id)).unwrap_or(ids[0]),
};
let ref_idx = ids.iter().position(|i| *i == reference).unwrap_or(0);
let area = doc.bounds();
@@ -100,6 +109,9 @@ fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
if moves.is_empty() {
return Err(EngineError::Other("Auto-Align couldn't find enough matching detail between the layers".into()));
}
if let Some(l) = moves.iter().find(|&&i| pinned(&doc, ids[i])).and_then(|&i| doc.layer(ids[i])) {
return Err(EngineError::Other(format!("Auto-Align can't move the locked layer \"{}\"", l.name)));
}
let warped: Vec<(LayerId, Surface, Option<Surface>)> = moves
.iter()
.map(|&i| {
@@ -122,6 +134,8 @@ fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
.collect::<Result<_>>()?;
s.edit("Auto-Align Layers", |doc, _| {
for (id, px, mask) in &warped {
// Photoshop turns the Background into a normal layer before moving it.
crate::extra_cmds::background_to_layer(doc, *id);
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
if let Some(surf) = l.surface_mut() {
*surf = px.clone();
@@ -331,7 +345,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.autoAlignLayers",
"Auto-Align Layers…",
r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##,
r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=the locked selected layer, else the bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##,
auto_align
),
spec!(
@@ -350,8 +364,15 @@ mod tests {
/// Deterministic texture written into a layer at offset (dx, dy).
fn texture_layer(s: &mut Session, name: &str, dx: i32, dy: i32) -> LayerId {
s.execute("layer.new.layer", json!({"name": name})).unwrap();
s.edit("tex", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let id = s.active().unwrap().active_layer.unwrap();
paint_texture(s, id, dx, dy);
id
}
/// Writes the deterministic texture into layer `id` at offset (dx, dy).
fn paint_texture(s: &mut Session, id: LayerId, dx: i32, dy: i32) {
s.edit("tex", |doc, _| {
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
let mut seed = 99u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
@@ -367,7 +388,6 @@ mod tests {
Ok(())
})
.unwrap();
s.active().unwrap().active_layer.unwrap()
}
fn select(s: &mut Session, ids: &[LayerId]) {
@@ -411,6 +431,86 @@ mod tests {
assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err());
}
/// As in Photoshop, a locked layer stays where it is: it becomes the reference and the
/// others move onto it (#7080).
#[test]
fn auto_align_keeps_a_locked_layer_as_the_reference() {
for lock in ["all", "position", "pixels"] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 300, "height": 240, "background": "transparent"})).unwrap();
let a = texture_layer(&mut s, "A", 20, 20);
let b = texture_layer(&mut s, "B", 31, 13);
s.edit("lock", |doc, _| {
doc.layer_mut(b).unwrap().locks.set(lock, true);
Ok(())
})
.unwrap();
select(&mut s, &[a, b]);
let r = s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).unwrap();
assert_eq!(r["reference"], b.0, "{lock}: {r}");
let doc = &s.active().unwrap().doc;
let bb = doc.layer(b).unwrap().surface().unwrap().content_bounds();
assert_eq!((bb.x0, bb.y0), (31, 13), "{lock}: the locked layer stays put");
let ab = doc.layer(a).unwrap().surface().unwrap().content_bounds();
assert!((ab.x0 - 31).abs() <= 1 && (ab.y0 - 13).abs() <= 1, "{lock}: {ab:?} {r}");
// An explicit reference that would move the locked layer is refused.
s.undo();
assert!(s.execute("edit.autoAlignLayers", json!({"projection": "reposition", "reference": a.0})).is_err());
// Two locked layers: nothing may move.
s.edit("lock", |doc, _| {
doc.layer_mut(a).unwrap().locks.set(lock, true);
Ok(())
})
.unwrap();
assert!(s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).is_err());
let bb = s.active().unwrap().doc.layer(a).unwrap().surface().unwrap().content_bounds();
assert_eq!((bb.x0, bb.y0), (20, 20));
}
}
/// The Background doesn't block Auto-Align: when a projection reprojects every layer
/// (Cylindrical, Spherical, or Auto choosing one), or another layer is the reference, it
/// becomes a normal layer ("Layer 0") and moves, as in Photoshop and as Free Transform does.
#[test]
fn auto_align_moves_the_background_as_a_normal_layer() {
let setup = || {
let mut s = Session::new();
s.execute("file.new", json!({"width": 300, "height": 240, "background": "white"})).unwrap();
let bg = s.active().unwrap().doc.background().unwrap().id;
paint_texture(&mut s, bg, 20, 20);
let b = texture_layer(&mut s, "B", 31, 13);
select(&mut s, &[bg, b]);
(s, bg, b)
};
for projection in ["cylindrical", "spherical", "auto"] {
let (mut s, bg, b) = setup();
let r = s.execute("edit.autoAlignLayers", json!({"projection": projection})).unwrap_or_else(|e| panic!("{projection}: {e}"));
let doc = &s.active().unwrap().doc;
let moved = r["aligned"].as_array().unwrap().iter().any(|a| a["layer"] == bg.0);
assert_eq!(moved, projection != "auto" || r["layout"] != "perspective", "{projection}: {r}");
assert_eq!(doc.is_background(bg), !moved, "{projection}: a moved Background is a normal layer: {r}");
if moved {
assert_eq!(doc.layer(bg).unwrap().name, "Layer 0", "{projection}");
}
assert!(doc.layer(b).is_some(), "{projection}");
s.undo();
assert!(s.active().unwrap().doc.is_background(bg), "{projection}: undo restores the Background");
}
// Planar, with the other layer as the reference: the Background moves onto it.
let (mut s, bg, b) = setup();
s.execute("edit.autoAlignLayers", json!({"projection": "reposition", "reference": b.0})).unwrap();
let doc = &s.active().unwrap().doc;
assert!(!doc.is_background(bg));
// Default reference: the Background (bottom) stays put and stays the Background.
let (mut s, bg, b) = setup();
let r = s.execute("edit.autoAlignLayers", json!({"projection": "reposition"})).unwrap();
assert_eq!(r["reference"], bg.0, "{r}");
let doc = &s.active().unwrap().doc;
assert!(doc.is_background(bg));
let bb = doc.layer(b).unwrap().surface().unwrap().content_bounds();
assert!((bb.x0 - 20).abs() <= 1 && (bb.y0 - 20).abs() <= 1, "{bb:?} {r}");
}
/// The moving layer's mask is warped as a mask: one grey channel whose outside keeps its
/// default (reveal-all stays revealed, hide-all stays hidden), for planar and mesh warps.
#[test]
Loaded 100 of 433 files, more files were not shown because too many files have changed in this diff. Show more