mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 19:46:48 +01:00
Merge upstream/main into fix/3290-filter-mask-paint
This commit is contained in:
commit
053e1e0d03
433 files changed
+29494
-4706
No files matched your search
+150
-185
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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…)");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,4 +21,5 @@ rustfft = "6.4.1"
|
||||
rayon = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
photocraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 ° 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.
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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] {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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,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;
|
||||
@@ -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
@@ -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 _};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}})),
|
||||
] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 }] }
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 }];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in new issue
Block a user