Integration: merge main (#8175)

Resolves the conflicts with main's area type (TextShape::Area): EngineData keeps main's area arm beside the path comment, and a Txt2 frame of an area layer becomes a text box over the path's bounds, as its EngineData describes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-25 12:14:04 +01:00
769 changed files with 36860 additions and 6627 deletions
+150 -185
View File
@@ -1,10 +1,14 @@
name: CI
# Quality gates for every push to main and every pull request. The Linux gate runs through the
# suite's shared gate workflow in craft-common-actions (`cargo xtask ci`); the Windows legs, the
# test variant and the corpus, web and fuzz jobs are PhotoCraft's own.
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it.
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
fuzz:
@@ -15,6 +19,10 @@ on:
description: "Seconds per fuzz target"
default: "600"
# The token is only used to check out the code: no job writes to the repository.
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
@@ -22,52 +30,72 @@ env:
# bypasses that, lp fails loudly on a printer that does not exist.
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of
# disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job).
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
# that lands while main's run is in progress waits for it, so every main state that gets a run is
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
# A newer push to a PR branch cancels its older run. Every push to main gets a run of its own that
# is never cancelled, so each state of main is checked to the end. Adding any label but `full-ci`
# gets a group of its own, so it neither runs anything nor cancels a run.
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
# platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
# the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
# macOS is tested only by release.yml.
# Whether the Windows legs and the web build run. main, manual runs and PRs labelled `full-ci`
# get both. Other PRs get Windows when they touch code whose behaviour differs by platform: the
# app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or the lockfile.
# Format changes must exercise the Windows-only publication barriers (#8034). macOS is tested
# only by release.yml.
platforms:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
timeout-minutes: 10
name: select platforms
runs-on: ubuntu-latest
outputs:
legs: ${{ steps.pick.outputs.legs }}
windows: ${{ steps.pick.outputs.windows }}
web: ${{ steps.pick.outputs.web }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
- id: pick
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.event.pull_request.base.sha }}
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
run: |
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As
# one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting
# the integration tests over a third runner (nextest --partition) saved ~2 min of wall
# time but cost ~7 runner-minutes and one more Windows slot per PR.
linux='{"os":"ubuntu-latest","part":"all"}'
win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; }
all="[$linux,$(win unit),$(win integration)]"
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "windows=true" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -80,66 +108,82 @@ jobs:
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "windows=true" >> "$GITHUB_OUTPUT"
else
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
echo "windows=false" >> "$GITHUB_OUTPUT"
fi
test:
timeout-minutes: 100
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
# The Linux gate. Its rust-cache entry is the one `test (without heif)` restores.
gate:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# egui/winit/rfd.
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
# The tests build with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run. Same commit as release.yml.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
# Cargo.toml's dev profile already keeps line tables only.
dev-debug: ""
env: |
PHOTOCRAFT_NO_PRINT=1
LPDEST=photocraft-no-such-printer
timeout-minutes: 100
# fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
# `--locked --features heif`, then the missing-translations report (xtask/src/main.rs).
command: cargo xtask ci
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job
# they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration
# tests over a third runner (nextest --partition) saved ~2 min of wall time but cost ~7
# runner-minutes and one more Windows slot per PR. They stay outside the shared gate for sccache,
# which needs its own action step.
windows:
needs: platforms
runs-on: ${{ matrix.os }}
if: needs.platforms.outputs.windows == 'true'
timeout-minutes: 100
name: test (windows-latest, ${{ matrix.part }})
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
part: [unit, integration]
env:
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
# line-tables-only, so a backtrace with lines is one OS away).
CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
CARGO_PROFILE_DEV_DEBUG: "0"
steps:
- uses: actions/checkout@v4
# Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the
# background: the space is free before the craft-fonts build needs it, and nothing waits.
- name: Install Linux system dependencies (egui/winit/rfd)
if: runner.os == 'Linux'
run: |
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
components: clippy
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
# Only main saves caches; PRs restore main's, so the repo's 10 GB cache budget keeps main's.
# A failing test still saves: the cache holds compiled dependencies, not test results.
# The Windows legs share one key (same dependencies); the integration leg saves it,
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
# Both legs share one key (same dependencies); the integration leg saves it, because it also
# runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
save-if: ${{ github.ref == 'refs/heads/main' && matrix.part == 'integration' }}
cache-on-failure: true
# sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
# warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
# main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
# Test executables can't be cached, so a PR still links its tests.
- name: Install sccache (Windows)
if: runner.os == 'Windows'
- name: Install sccache
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Use sccache (Windows)
if: runner.os == 'Windows'
- name: Use sccache
env:
MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
@@ -148,149 +192,80 @@ jobs:
if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
shell: bash
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
- name: Clippy
if: matrix.part == 'all' || matrix.part == 'integration'
if: matrix.part == 'integration'
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
if: matrix.part == 'all' || matrix.part == 'integration'
if: matrix.part == 'integration'
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
# prefs audit). Static: it reads committed files only, no benchmarks run here.
- name: Scorecard up to date
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
# nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
# GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
- uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
with:
tool: cargo-nextest@0.9.146
tool: cargo-nextest@0.9.149
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
# `--lib --bins --tests` leaves out the examples, which cargo test would build and link but
# never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so
# they get their own step.
#
# On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
# The unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
# integration-test target); the feature selection stays the same, so both legs share the
# dependency cache. The `ci` profile fails fast and terminates hung tests
# (.config/nextest.toml); assertion failures are not retried.
# dependency cache.
- name: Test
env:
PART: ${{ matrix.part }}
run: |
case "$PART" in
unit) targets=(--lib --bins) ;;
integration) targets=(--test '*') ;;
*) targets=(--lib --bins --tests) ;;
*) targets=(--test '*') ;;
esac
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
shell: bash
# Every command against adversarial params must return Err, never panic or hang (AGENTS.md
# Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as
# Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency
# features and recompile the whole graph. The `ci` profile terminates it if it hangs.
- name: Panic hunt
if: runner.os == 'Linux'
run: >-
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
--run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)'
- name: Doctests
if: matrix.part == 'all' || matrix.part == 'unit'
if: matrix.part == 'unit'
run: cargo test --workspace --locked --features heif --doc
# The Windows tests used to be one job named `test (windows-latest)`; they're now legs named
# `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old
# name reporting, so a required status check on it (none today) would keep working. It fails
# unless the whole test matrix and both Linux variants passed. `needs` reports one result
# for all matrix legs; when a PR runs no Windows legs it still checks every Linux result.
windows-gate:
timeout-minutes: 5
name: test (windows-latest)
needs: [platforms, test, test-variants]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build" error
# for distributors who leave it out. Runs beside the gate and restores the gate's dependency
# cache without saving one: the dependencies are the same, and the 10 GB cache budget is tight
# (#2688).
test-without-heif:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
timeout-minutes: 60
name: test (without heif)
# The gate's runner: its cache entry is keyed on the OS.
runs-on: ubuntu-24.04
env:
# The gate sets these; rust-cache hashes CARGO_* and RUST* variables into its key.
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
steps:
- env:
PLATFORMS: ${{ needs.platforms.result }}
TEST: ${{ needs.test.result }}
VARIANTS: ${{ needs.test-variants.result }}
run: |
echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
[ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The two other builds of the Linux tests, in parallel with the test job instead of after it (they
# added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is
# that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is
# tight (#2688).
test-variants:
timeout-minutes: 100
name: test (${{ matrix.variant }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
variant: [without heif, with craft-fonts]
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
# gate.yml keys its cache `<cache-version>-rust-<hash of Cargo.toml>-gate`.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: test
prefix-key: v1-rust
shared-key: ${{ hashFiles('Cargo.toml') }}-gate
save-if: false
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
- uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
with:
tool: cargo-nextest@0.9.146
tool: cargo-nextest@0.9.149
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: matrix.variant == 'without heif'
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
# The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese
# font tests that skip without it run. Keep the test job's exact `--workspace --features heif
# --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds.
# Filter execution to the three crates that read the optional font input (#6127).
- name: Check out craft-fonts
if: matrix.variant == 'with craft-fonts'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Test with craft-fonts
if: matrix.variant == 'with craft-fonts'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: >-
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
timeout-minutes: 30
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
@@ -306,7 +281,9 @@ jobs:
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
@@ -314,7 +291,7 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Restore test corpora
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: corpus/
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
@@ -323,23 +300,6 @@ jobs:
- name: Corpus tests
run: cargo xtask test-corpus
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Check wasm-safe crates
run: cargo xtask wasm
web:
timeout-minutes: 60
name: web build and wasm size gate
@@ -351,7 +311,9 @@ jobs:
# Match release.yml so PR results reflect what would actually ship.
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
@@ -409,6 +371,7 @@ jobs:
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { crate: psd, name: asl }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
@@ -419,7 +382,9 @@ jobs:
- { crate: affinity, name: import_preview, args: "--features io" }
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
@@ -433,7 +398,7 @@ jobs:
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
+2
View File
@@ -9,6 +9,8 @@ name: FreeBSD
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
on:
push:
branches: [release]
schedule:
- cron: "17 2 * * *"
pull_request:
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
+2
View File
@@ -7,6 +7,8 @@ name: Windows ARM64
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
# change the Windows packaging itself.
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
+1
View File
@@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## 4. Picking work
+2
View File
@@ -88,6 +88,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
| `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
Generated
+215 -35
View File
@@ -241,12 +241,56 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -993,6 +1037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
@@ -1001,8 +1046,31 @@ version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
@@ -1011,6 +1079,16 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "clap_mangen"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
dependencies = [
"clap",
"roff",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1079,6 +1157,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "colored"
version = "2.2.0"
@@ -1227,6 +1311,14 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-jpeg"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -3066,6 +3158,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
@@ -4403,6 +4501,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
@@ -4638,10 +4742,12 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"arboard",
"async-io",
"chrono",
"clap",
"core-graphics2",
"dispatch2",
"eframe",
@@ -4672,6 +4778,8 @@ dependencies = [
"ron",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"ureq",
"winit",
"winresource",
"winsafe",
@@ -4683,7 +4791,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4693,11 +4801,12 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"photocraft-testkit",
"rayon",
"rustfft",
"serde",
@@ -4706,10 +4815,11 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"base64 0.22.1",
"cap-std",
"clap",
"getrandom 0.3.4",
"photocraft-codecs",
"photocraft-compose",
@@ -4729,8 +4839,11 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"clap",
"clap_complete",
"clap_mangen",
"photocraft-automation",
"photocraft-codecs",
"photocraft-engine",
@@ -4738,13 +4851,14 @@ dependencies = [
"photocraft-io",
"photocraft-raw",
"serde_json",
"thiserror 2.0.21",
"tokio",
"winresource",
]
[[package]]
name = "photocraft-cms"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"moxcms",
"rayon",
@@ -4752,8 +4866,9 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"craft-jpeg",
"exr",
"flate2",
"half",
@@ -4773,7 +4888,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -4781,9 +4896,10 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"criterion",
"photocraft-cms",
"photocraft-color",
"photocraft-doc",
"photocraft-geom",
@@ -4796,7 +4912,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4807,9 +4923,10 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"blake3",
"chrono",
"craft-print",
"exr",
"flate2",
@@ -4830,11 +4947,13 @@ dependencies = [
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
"photocraft-testkit",
"photocraft-text",
"photocraft-vector",
"rayon",
"roxmltree 0.20.0",
"roxmltree 0.21.1",
"same-file",
"semver",
"serde",
"serde_json",
"tempfile",
@@ -4845,7 +4964,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"blake3",
"crc32fast",
@@ -4868,14 +4987,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"half",
"log",
@@ -4893,14 +5012,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"blake3",
"craft-docpath",
@@ -4919,29 +5038,30 @@ dependencies = [
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
"photocraft-testkit",
"photocraft-text",
"photocraft-vector",
"proptest",
"resvg",
"roxmltree 0.20.0",
"roxmltree 0.21.1",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ipp"
version = "0.12.0"
version = "0.15.1"
[[package]]
name = "photocraft-ops"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4952,12 +5072,13 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -4967,7 +5088,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4978,7 +5099,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4989,7 +5110,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"flate2",
"image",
@@ -5004,7 +5125,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -5016,7 +5137,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"memory-stats",
"serde_json",
@@ -5024,7 +5145,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"flate2",
"parley",
@@ -5039,7 +5160,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5082,7 +5203,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5093,7 +5214,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"eframe",
"egui",
@@ -5115,7 +5236,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"flate2",
"js-sys",
@@ -5949,6 +6070,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "roff"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "ron"
version = "0.12.2"
@@ -6051,12 +6178,14 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -7452,6 +7581,35 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -7491,12 +7649,24 @@ dependencies = [
"xmlwriter",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
@@ -7930,6 +8100,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -8711,8 +8890,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.15.1"
dependencies = [
"chrono",
"serde",
"serde_json",
"toml",
+13 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.15.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -41,9 +41,13 @@ photocraft-ipp = { path = "crates/ipp" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4.6.11"
clap_mangen = { version = "0.3.3", features = ["env"] }
arc-swap = "1"
bytemuck = { version = "1", features = ["derive"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
@@ -59,6 +63,8 @@ egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
criterion = { version = "0.5", default-features = false }
getrandom = "0.3"
@@ -70,15 +76,18 @@ unsafe_code = "forbid"
dbg_macro = "warn"
todo = "warn"
# Dependencies carry no debug info: their frames in a backtrace lose file:line, and the target dir
# and link times shrink. The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a
# debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
# time and PDB size for the test binaries (#2204). CI's Windows legs build with no debuginfo at all
# (CARGO_PROFILE_DEV_DEBUG=0). To inspect locals in a debugger, build with
# CARGO_PROFILE_DEV_DEBUG=true.
debug = "line-tables-only"
# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
+1 -1
View File
@@ -221,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
+1 -1
View File
@@ -137,7 +137,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
+6
View File
@@ -28,12 +28,18 @@ photocraft-engine = { workspace = true }
photocraft-io = { workspace = true }
photocraft-format = { path = "../../crates/format" }
photocraft-codecs = { workspace = true }
clap = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
# Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in
# the binary.
clap_complete = { workspace = true }
clap_mangen = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
@@ -0,0 +1,41 @@
//! Writes photocraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
//! PowerShell) and man pages ([`clap_mangen`]: `photocraft-cli.1` and one per subcommand, such as
//! `photocraft-cli-convert.1`) into a directory. The Linux and FreeBSD packages ship them
//! (`packaging/linux/package.sh`, `packaging/freebsd/package.sh`):
//!
//! ```sh
//! cargo run -p photocraft-cli --example man-and-completions -- <out-dir>
//! ```
use std::path::Path;
use std::process::ExitCode;
use clap::ValueEnum;
use clap_complete::Shell;
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
let (Some(dir), None) = (args.next(), args.next()) else {
eprintln!("usage: man-and-completions <out-dir>");
return ExitCode::from(2);
};
let dir = Path::new(&dir);
match generate(dir) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}: {e}", dir.display());
ExitCode::FAILURE
}
}
}
fn generate(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut cmd = photocraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", dir)?;
}
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
// which `clap_mangen` would otherwise give pages of its own (`photocraft-cli-help-convert.1`).
clap_mangen::generate_to(photocraft_cli::command(), dir)
}
+116
View File
@@ -0,0 +1,116 @@
use std::path::{Path, PathBuf};
use photocraft_engine::actions_cmds::Action;
use serde_json::{Value, json};
/// Why an actions file can't be played.
#[derive(Debug, thiserror::Error)]
pub enum ActionsError {
#[error("actions JSON: {0}")]
Json(#[source] serde_json::Error),
/// The steps don't parse ([`photocraft_engine::automate_cmds::parse_action`]).
#[error(transparent)]
Steps(#[from] photocraft_engine::EngineError),
#[error("--action picks an action from an Actions set ({{\"actions\": [{{\"name\", \"steps\"}}…]}}), but this file holds one action's steps")]
NotASet,
#[error("Actions set: {0}")]
Set(#[source] serde_json::Error),
#[error("no action named `{name}` in the set (it holds {})", quoted(names))]
NoSuchAction { name: String, names: Vec<String> },
#[error("the Actions set holds {} actions: pick one with --action <name> ({})", names.len(), quoted(names))]
PickOne { names: Vec<String> },
#[error("{}: {source}", path.display())]
Read { path: PathBuf, source: std::io::Error },
#[error("{}: {source}", path.display())]
Utf8 { path: PathBuf, source: std::string::FromUtf8Error },
/// A Photoshop Actions file that doesn't import ([`photocraft_engine::atn_cmds::import`]).
#[error("{}: {message}", path.display())]
Atn { path: PathBuf, message: String },
#[error("no action named `{name}` in the set `{set}` (it holds {})", quoted(names))]
NoSuchAtnAction { name: String, set: String, names: Vec<String> },
#[error("{}: the set holds no actions", path.display())]
EmptyAtn { path: PathBuf },
#[error("the set `{set}` holds {} actions: pick one with --action <name> ({})", names.len(), quoted(names))]
PickOneAtn { set: String, names: Vec<String> },
}
/// `` `a`, `b` ``: the action names an error lists.
fn quoted(names: &[String]) -> String {
names.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ")
}
fn names(actions: &[Action]) -> Vec<String> {
actions.iter().map(|a| a.name.clone()).collect()
}
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, ActionsError> {
let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?;
steps_of(&v)
}
fn steps_of(v: &Value) -> Result<Steps, ActionsError> {
Ok(photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions")?)
}
/// Engine commands to run in order: `(id, params)`.
pub type Steps = Vec<(String, Value)>;
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), ActionsError> {
let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?;
let list = v.get("actions").unwrap_or(&v);
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
if !is_set {
if name.is_some() {
return Err(ActionsError::NotASet);
}
return Ok((steps_of(&v)?, Vec::new()));
}
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(ActionsError::Set)?;
let action = match (name, set.as_slice()) {
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAction { name: n.to_owned(), names: names(&set) })?,
(None, [only]) => only,
(None, _) => return Err(ActionsError::PickOne { names: names(&set) }),
};
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), ActionsError> {
let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?;
let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?;
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => imp.actions.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAtnAction {
name: n.to_owned(),
set: imp.set.clone(),
names: names(&imp.actions),
})?,
(None, [only]) => only,
(None, []) => return Err(ActionsError::EmptyAtn { path: path.to_path_buf() }),
(None, _) => return Err(ActionsError::PickOneAtn { set: imp.set.clone(), names: names(&imp.actions) }),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
+428
View File
@@ -0,0 +1,428 @@
//! The command line, as clap types. Values are parsed into their types here, so a bad one is a
//! usage error naming its flag (exit 2) and the handlers never see it (#7071). A flag a
//! subcommand doesn't take is a usage error too, so a typo such as `--fromat` can't be silently
//! ignored (#423), and so is a repeated one: `--quality 80 --quality 60` is more likely a mistake
//! than a choice. A value starting with `-` is written `--out=-image.png` (or `./-image.png`), so a
//! value flag never takes the next flag as its value; negative numbers are kept where they mean
//! something.
use std::ffi::OsString;
use std::fmt;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use clap::error::ErrorKind;
use clap::parser::ValueSource;
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
use photocraft_codecs::{JpegFormat, TiffCompression};
use photocraft_io::{DdsCompression, DpxBits, TgaBits};
use serde_json::Value;
/// photocraft-cli: headless PhotoCraft
#[derive(Parser)]
#[command(name = "photocraft-cli", version = photocraft_engine::build_info::long_version_static(), arg_required_else_help = true)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Subcommand)]
pub(crate) enum Command {
Convert(ConvertArgs),
Info(InfoArgs),
Run(RunArgs),
Batch(BatchArgs),
Droplet(DropletArgs),
Commands(CommandsArgs),
Mcp(McpArgs),
Serve(ServeArgs),
}
// How an image is written: shared by `convert`, `run` and `batch`.
#[derive(Args)]
pub(crate) struct ExportArgs {
/// Output format, overriding the output's extension (png, jpg, tif, webp, …)
#[arg(long, value_name = "EXT")]
pub(crate) format: Option<String>,
/// JPEG or WebP quality, 1-100. A WebP written with a quality is lossy, without one lossless
// allow_negative_numbers makes `--quality -1` an out-of-range value rather than an unexpected argument.
#[arg(long, value_name = "1-100", value_parser = clap::value_parser!(u8).range(1..=100), allow_negative_numbers = true)]
pub(crate) quality: Option<u8>,
/// Keep the layers in TIFF output (Photoshop layer data); TIFF output is flat without it
#[arg(long)]
pub(crate) tiff_layers: bool,
/// Compress TIFF output: none (the default), lzw or zip
#[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)]
pub(crate) tiff_compression: Option<TiffCompression>,
/// A TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its
/// alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24
#[arg(long, value_name = "24|32", value_parser = tga_bits)]
pub(crate) tga_bits: Option<TgaBits>,
/// A DPX's bits per sample. Without it: 8 for an 8-bit document, else 16
#[arg(long, value_name = "8|10|12|16", value_parser = dpx_bits)]
pub(crate) dpx_bits: Option<DpxBits>,
/// A DDS texture's compression: none (uncompressed, the default), bc1 (DXT1: alpha on or
/// off), bc2 (DXT3) or bc3 (DXT5)
#[arg(long, value_name = "none|bc1|bc2|bc3", value_parser = dds_compression)]
pub(crate) dds_compression: Option<DdsCompression>,
/// Also write a DDS texture's full mip chain
#[arg(long)]
pub(crate) dds_mipmaps: bool,
/// A JPEG's scans, as JPEG Options picks them: baseline (the default), optimized (Huffman
/// tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so only Gray
/// and CMYK shrink) or progressive
#[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)]
pub(crate) jpeg_format: Option<JpegFormat>,
}
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
///
/// An <OUT> that is the <IN> file is refused, as the result would replace the original;
/// --in-place allows it.
#[derive(Args)]
pub(crate) struct ConvertArgs {
/// The file to read
#[arg(value_name = "IN")]
pub(crate) input: PathBuf,
/// The file to write; its extension picks the format unless --format is given
#[arg(value_name = "OUT")]
pub(crate) output: PathBuf,
/// Allow an <OUT> that is the <IN> file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
}
/// Print the document as JSON (size, mode, depth, layer tree)
#[derive(Args)]
pub(crate) struct InfoArgs {
pub(crate) file: PathBuf,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Open a file (or start a new document), run engine commands in order, save the result
///
/// Prints each command's JSON result. Give exactly one of <FILE> or --new, and at least one --cmd
/// or --out. An --out that is the <FILE> is refused, as the result would replace the original;
/// --in-place allows it.
#[derive(Args)]
#[command(group(ArgGroup::new("source").args(["file", "new"]).required(true)))]
#[command(group(ArgGroup::new("work").args(["cmd", "out"]).required(true).multiple(true)))]
pub(crate) struct RunArgs {
/// The document to open
pub(crate) file: Option<PathBuf>,
/// Start from a new document with these JSON parameters instead of opening a file
#[arg(long, value_name = "JSON", value_parser = json)]
pub(crate) new: Option<Value>,
/// An engine command to run; repeat for several, run in order
#[arg(long, value_name = "ID", action = ArgAction::Append)]
pub(crate) cmd: Vec<String>,
/// JSON parameters for the --cmd before it (default {})
#[arg(long, value_name = "JSON", action = ArgAction::Append, value_parser = json)]
pub(crate) params: Vec<Value>,
/// Save the result to this file
#[arg(long, value_name = "FILE")]
pub(crate) out: Option<PathBuf>,
/// Allow an --out that is the <FILE>, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
/// `--cmd` and `--params` values in command-line order, set by [`parse`].
#[arg(skip)]
pub(crate) steps: Vec<Step>,
}
/// One `--cmd` or `--params` of `run`, in command-line order.
pub(crate) enum Step {
Cmd(String),
Params(Value),
}
/// Apply an action list to every image in a directory
///
/// A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps
/// are [id, params] pairs, {"command": id, "params": {…}} objects or bare ids, as a recorded
/// action or droplet stores them (a list, or wrapped in {"actions": …}, {"steps": …} or a
/// droplet). An Actions set (the Actions panel's actions.json, {"actions": [{"name", "steps"}…]})
/// plays the action --action names (needed when the set holds more than one) with the whole set
/// loaded, so its steps can play other actions of the set.
#[derive(Args)]
pub(crate) struct BatchArgs {
/// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn)
#[arg(long, value_name = "FILE")]
pub(crate) actions: PathBuf,
/// Which action of an Actions set to play
#[arg(long, value_name = "NAME")]
pub(crate) action: Option<String>,
/// The folder of images to process
#[arg(long = "in", value_name = "DIR")]
pub(crate) in_dir: PathBuf,
/// The folder to write the results to, as <stem>.<ext>
#[arg(long, value_name = "DIR")]
pub(crate) out: PathBuf,
/// An --out folder that is the --in folder is refused, as the results would replace the
/// originals; --in-place allows it
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
}
/// Run a droplet (File › Automate › Create Droplet) on images and folders
#[derive(Args)]
pub(crate) struct DropletArgs {
/// The droplet to run
#[arg(value_name = "FILE.pcdroplet")]
pub(crate) file: String,
/// Images and folders to run it on
#[arg(value_name = "FILE-OR-DIR", required = true)]
pub(crate) inputs: Vec<String>,
/// The folder to write the results to
#[arg(long, value_name = "DIR")]
pub(crate) out: Option<String>,
}
/// List the engine command registry
#[derive(Args)]
pub(crate) struct CommandsArgs {
/// Print the entries as JSON
#[arg(long)]
pub(crate) json: bool,
/// Only the commands matching this text
#[arg(long, value_name = "TEXT", allow_negative_numbers = true)]
pub(crate) filter: Option<String>,
}
/// Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control <port>`)
///
/// Headless: each automation root grants that direction of file access; an omitted root denies it.
/// Prefer absolute root paths in MCP client configurations. Bridge: set the roots on the desktop
/// app; root flags are refused. The token flags require --bridge (or PHOTOCRAFT_CONTROL_PORT).
#[derive(Args)]
pub(crate) struct McpArgs {
/// Bridge to the PhotoCraft app listening here instead of a headless engine; a bare port means
/// 127.0.0.1:<PORT>
#[arg(long, value_name = "127.0.0.1:PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = bridge)]
pub(crate) bridge: Option<Bridge>,
/// `--bridge` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`].
#[arg(skip)]
pub(crate) bridge_from_env: bool,
#[command(flatten)]
pub(crate) control: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
}
/// Keep one headless session open and answer JSON lines on stdio, or on 127.0.0.1:<PORT>
///
/// Each request is a JSON line {"id", "method", "params"}. Methods: engine.execute,
/// jobs.list/cancel, engine.commands,
/// doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close,
/// session.list, batch, methods (docs/control-protocol.md#headless-server). The token flags require
/// --port (or PHOTOCRAFT_CONTROL_PORT); stdio needs no control token.
#[derive(Args)]
pub(crate) struct ServeArgs {
/// Listen on 127.0.0.1:<PORT> instead of stdio
#[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port)]
pub(crate) port: Option<u16>,
/// `--port` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`].
#[arg(skip)]
pub(crate) port_from_env: bool,
#[command(flatten)]
pub(crate) control: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
enum ValueError {
#[error("expected 24 or 32")]
TgaBits,
#[error("expected 8, 10, 12 or 16")]
DpxBits,
#[error("expected none, bc1, bc2 or bc3")]
DdsCompression,
#[error("expected none, lzw or zip")]
TiffCompression,
#[error("expected baseline, optimized or progressive")]
JpegFormat,
/// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused.
#[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)")]
Bridge,
}
/// Why `parse` refuses `mcp` or `serve` options clap accepted: clap prints it as a usage error
/// (exit 2).
#[derive(Debug, thiserror::Error)]
enum ServerOptionError {
/// PHOTOCRAFT_CONTROL_PORT holds a port, as for the desktop app's --control: only the flag
/// takes host:port.
#[error("invalid value '{0}' for 'PHOTOCRAFT_CONTROL_PORT': {bridge}", bridge = ValueError::Bridge)]
BridgeVar(String),
/// With --bridge the roots are the desktop process's to set: accepting them here promises
/// authority the bridge can't give (#8035).
#[error("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode")]
BridgeRoots,
/// A token flag without the bridge or port that reads it would be ignored (#8035).
#[error("{flag} requires {transport}")]
NeedsTransport { flag: &'static str, transport: &'static str },
}
impl ServerOptionError {
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
let kind = match self {
Self::BridgeVar(_) => ErrorKind::ValueValidation,
Self::BridgeRoots => ErrorKind::ArgumentConflict,
Self::NeedsTransport { .. } => ErrorKind::MissingRequiredArgument,
};
cmd.error(kind, self)
}
}
fn tga_bits(s: &str) -> Result<TgaBits, ValueError> {
s.parse().ok().and_then(TgaBits::from_bits).ok_or(ValueError::TgaBits)
}
fn dpx_bits(s: &str) -> Result<DpxBits, ValueError> {
s.parse().ok().and_then(DpxBits::from_bits).ok_or(ValueError::DpxBits)
}
fn dds_compression(s: &str) -> Result<DdsCompression, ValueError> {
DdsCompression::from_name(s).ok_or(ValueError::DdsCompression)
}
fn tiff_compression(s: &str) -> Result<TiffCompression, ValueError> {
match s {
"none" => Ok(TiffCompression::None),
"lzw" => Ok(TiffCompression::Lzw),
"zip" => Ok(TiffCompression::Deflate),
_ => Err(ValueError::TiffCompression),
}
}
fn jpeg_format(s: &str) -> Result<JpegFormat, ValueError> {
JpegFormat::parse(s).ok_or(ValueError::JpegFormat)
}
/// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:<PORT>` for a port.
#[derive(Clone)]
pub(crate) enum Bridge {
/// A bare port: the app on 127.0.0.1.
Port(u16),
/// `host:port`, as given. Whether the host is loopback is the bridge's to say.
Addr(String),
}
impl fmt::Display for Bridge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Bridge::Port(port) => SocketAddr::from((Ipv4Addr::LOCALHOST, *port)).fmt(f),
Bridge::Addr(addr) => f.write_str(addr),
}
}
}
/// `mcp --bridge`, or PHOTOCRAFT_CONTROL_PORT, which holds a port.
fn bridge(s: &str) -> Result<Bridge, ValueError> {
match control_port(s) {
Ok(port) => Ok(Bridge::Port(port)),
Err(_) if s.contains(':') => Ok(Bridge::Addr(s.to_owned())),
Err(_) => Err(ValueError::Bridge),
}
}
/// `--new` and `--params`: bad JSON is a usage error, before any work. Whether the value is an
/// object is the command's to say.
fn json(s: &str) -> Result<Value, serde_json::Error> {
serde_json::from_str(s)
}
/// `--cmd` and `--params` values back in command-line order: clap keeps each flag's values apart,
/// their indices say how they interleave.
fn steps(m: &ArgMatches) -> Vec<Step> {
let mut steps: Vec<(usize, Step)> = Vec::new();
if let (Some(at), Ok(Some(ids))) = (m.indices_of("cmd"), m.try_get_many::<String>("cmd")) {
steps.extend(at.zip(ids).map(|(i, id)| (i, Step::Cmd(id.clone()))));
}
if let (Some(at), Ok(Some(params))) = (m.indices_of("params"), m.try_get_many::<Value>("params")) {
steps.extend(at.zip(params).map(|(i, p)| (i, Step::Params(p.clone()))));
}
steps.sort_by_key(|s| s.0);
steps.into_iter().map(|(_, s)| s).collect()
}
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not
/// be valid Unicode (#1108), text must. A flag left out falls back to its environment variable,
/// unless that is empty, and a bad value from one is a usage error naming the variable (#2491). A
/// bare `--help` or `-h` is help even after a mistake on the same line (`--out --help`, `mcp
/// stray.psd --help`): clap reports the mistake first.
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset));
let argv = std::iter::once(OsString::from("photocraft-cli")).chain(args.iter().cloned());
let matches = match cmd.try_get_matches_from_mut(argv) {
Err(e) if args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => {
let help = [OsString::from("photocraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")];
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
}
matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?,
};
let mut cli = Cli::from_arg_matches(&matches)?;
let from_env = |m: &ArgMatches, id: &str| m.value_source(id) == Some(ValueSource::EnvVariable);
match (&mut cli.command, matches.subcommand()) {
(Command::Run(r), Some((_, m))) => r.steps = steps(m),
(Command::Mcp(a), Some((name, m))) => {
a.bridge_from_env = from_env(m, "bridge");
if a.bridge_from_env
&& let Some(Bridge::Addr(v)) = &a.bridge
{
return Err(ServerOptionError::BridgeVar(v.clone()).into_clap(&mut cmd));
}
if let Some(sub) = cmd.find_subcommand_mut(name) {
// Only the bridge reads a token, so a stale exported pair doesn't stop a headless
// run.
if a.bridge.is_some() {
conflict(sub, m, "control_token", "control_token_file")?;
}
// The roots are the headless engine's: the variable switching to the bridge would
// drop them, and the error names it.
if a.bridge_from_env {
conflict(sub, m, "automation_read_root", "bridge")?;
conflict(sub, m, "automation_write_root", "bridge")?;
}
if a.bridge.is_some() && (a.automation.automation_read_root.is_some() || a.automation.automation_write_root.is_some()) {
return Err(ServerOptionError::BridgeRoots.into_clap(sub));
}
check_server(sub, m, a.bridge.is_some(), "--bridge")?;
}
}
(Command::Serve(a), Some((name, m))) => {
a.port_from_env = from_env(m, "port");
if let Some(sub) = cmd.find_subcommand_mut(name) {
// Only the TCP server reads a token, so a stale exported pair doesn't stop stdio.
if a.port.is_some() {
conflict(sub, m, "control_token", "control_token_file")?;
}
check_server(sub, m, a.port.is_some(), "--port")?;
}
}
_ => {}
}
Ok(cli)
}
/// Refuse a token flag without the bridge or port that reads it (as a flag or its variable). A
/// token variable alone may be a stale export, so it isn't refused.
fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, transport: &'static str) -> Result<(), clap::Error> {
for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] {
if m.value_source(id) == Some(ValueSource::CommandLine) && !has_transport {
return Err(ServerOptionError::NeedsTransport { flag, transport }.into_clap(sub));
}
}
Ok(())
}
+82
View File
@@ -0,0 +1,82 @@
use std::io::Write;
use std::net::{Ipv4Addr, SocketAddr};
use serde_json::Value;
use photocraft_automation::{Headless, PhotocraftMcp, security};
use crate::{
Result,
args::{CommandsArgs, McpArgs, ServeArgs},
output::{JsonStyle, print_json},
};
pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result {
let h = Headless::new();
let list = h.command_list();
let needle = a.filter.as_deref().map(str::to_lowercase);
let items: Vec<&Value> = list
.as_array()
.map(|v| v.iter().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.collect();
if a.json {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), JsonStyle::Pretty);
}
for c in items {
let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" > ")).unwrap_or_default();
writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu)?;
}
Ok(())
}
pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result {
use std::sync::{Arc, Mutex};
let h = Arc::new(Mutex::new(Headless::with_workspace(a.automation.workspace()?)));
match a.port {
Some(port) => {
if a.port_from_env {
// Name the mode switch: an exported variable changes what this command does, and
// unsetting it (or setting it empty) is the way back to stdio.
let _ = writeln!(err, "photocraft-cli: PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio (unset it to serve stdio)");
}
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string();
let (supplied, token_file) = (a.control.control_token.as_deref(), a.control.control_token_file.as_deref());
let token = security::server_token(supplied, token_file)?;
if let Some(path) = token_file {
let _ = writeln!(err, "photocraft-cli control token file: {}", path.display());
} else if supplied.is_none() {
let _ = writeln!(err, "photocraft-cli control token: {token}");
}
photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| {
let _ = writeln!(err, "photocraft-cli serving on {local}");
})?;
Ok(())
}
None => {
let stdin = std::io::stdin();
Ok(photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout())?)
}
}
}
pub(crate) fn mcp(a: &McpArgs, err: &mut dyn Write) -> Result {
let server = match &a.bridge {
Some(addr) => {
if a.bridge_from_env {
// Name the mode switch, as `serve` does: the way back to headless is to unset it.
let _ = writeln!(
err,
"photocraft-cli: PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on {addr} instead of a headless engine (unset it to run headless)"
);
}
let token = security::client_token(a.control.control_token.as_deref(), a.control.control_token_file.as_deref())?;
PhotocraftMcp::bridge(&addr.to_string(), &token)?
}
None => PhotocraftMcp::headless_with_workspace(a.automation.workspace()?),
};
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
Ok(rt.block_on(server.serve_stdio())?)
}
+210
View File
@@ -0,0 +1,210 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use photocraft_automation::Headless;
use photocraft_engine::actions_cmds::Action;
use photocraft_io::ExportOptions;
use serde_json::json;
use crate::{
CliError, Result,
actions::load_actions_file,
args::{BatchArgs, DropletArgs},
output::warn_all,
};
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
pub(crate) fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
fn strip_shell_steps(action: &mut Action) {
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
if shell_save_command(&id) || shell_view_command(&id) {
continue;
}
if action.disabled.contains(&i) {
disabled.insert(steps.len());
}
if action.dialogs.contains(&i) {
dialogs.insert(steps.len());
}
steps.push((id, p));
}
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
}
pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (in_dir, out_dir) = (a.in_dir.as_path(), a.out.as_path());
let (actions, mut set, warnings) = load_actions_file(&a.actions, a.action.as_deref())?;
set.iter_mut().for_each(strip_shell_steps);
warn_all(err, &warnings);
let opts = ExportOptions::from(&a.export);
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
if !a.in_place && same_dir(in_dir, out_dir) {
return Err(CliError::OutIsIn(out_dir.to_path_buf()));
}
std::fs::create_dir_all(out_dir)?;
let mut inputs: Vec<PathBuf> = std::fs::read_dir(in_dir)
.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?
.flatten()
.map(|e| e.path())
.filter(|p| is_input(p))
.collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.export.format.as_deref().map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let r = (|| -> Result<Vec<String>> {
written.check(&target_text).map_err(CliError::OutputClaimed)?;
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
h.open(input)?;
for (id, p) in &actions {
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
continue;
}
let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
return Err(CliError::NestedFailure { id: id.clone(), message });
}
}
let r = h.save(None, Some(&target), Some(&ext), &opts)?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
})();
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
Err(e) => {
failed += 1;
let _ = writeln!(err, "FAIL {}: {e}", input.display());
}
}
}
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) }
}
pub(crate) fn droplet(a: &DropletArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (file, inputs) = (&a.file, &a.inputs);
let mut p = json!({"droplet": file, "input": inputs});
if let Some(o) = &a.out {
p["output"] = json!(o);
}
let mut s = photocraft_engine::Session::new();
let r = s.execute("file.automate.runDroplet", p)?;
for f in r["files"].as_array().into_iter().flatten() {
let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default());
}
let errors = r["errors"].as_array().cloned().unwrap_or_default();
for e in &errors {
let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default());
}
if errors.is_empty() { Ok(()) } else { Err(CliError::FilesFailed(errors.len())) }
}
#[cfg(test)]
mod batch_action_set_tests {
use super::*;
use crate::test_support::{Invoke, invoke};
use serde_json::Value;
/// A batched action that plays another action of its set finds it in every file's session; a
/// failure inside the called action fails the file, and a self-call stops instead of looping.
#[test]
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
for name in ["a", "b"] {
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
assert_eq!(code, 0, "{stderr}");
}
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
let Invoke { code, stdout, stderr } = batch("Outer");
assert_eq!(code, 0, "{stdout}{stderr}");
for name in ["a", "b"] {
let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
}
for action in ["Calls bad", "Loop"] {
let Invoke { code, stdout, stderr } = batch(action);
assert_ne!(code, 0, "{stdout}");
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Save and view steps recorded inside a batched action set are skipped, also in a called
/// action, and unchecked steps stay unchecked once they are gone (#6860).
#[test]
fn batch_skips_save_and_view_steps_inside_an_action_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
{"name": "Rotate", "steps": [
["file.save", {}],
["image.imageRotation.90cw", {}],
["view.fitOnScreen", {}],
["image.imageRotation.90cw", {}],
["file.saveAs", {}],
], "disabled": [3], "dialogs": [1]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
for action in ["Outer", "Rotate"] {
let Invoke { code, stdout, stderr } =
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
assert_eq!(code, 0, "{stdout}{stderr}");
let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+226
View File
@@ -0,0 +1,226 @@
use std::io::Write;
use std::path::Path;
use photocraft_automation::{Headless, files};
use serde_json::{Value, json};
use photocraft_engine::type_extra_cmds::missing_font_warnings;
use photocraft_io::ExportOptions;
use crate::{
CliError, Result,
args::{ConvertArgs, InfoArgs, RunArgs, Step},
output::{JsonStyle, print_json, warn_all},
};
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
/// against mistakes, it is not a security boundary.
fn refuse_overwriting_input(in_place: bool, input: &Path, output: &Path) -> Result {
if !in_place && photocraft_engine::file_cmds::same_file(input, output) {
return Err(CliError::OutIsInput(output.to_path_buf()));
}
Ok(())
}
pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (input, output) = (&a.input, &a.output);
let opts = ExportOptions::from(&a.export);
refuse_overwriting_input(a.in_place, input, output)?;
let mut o = files::open(input)?;
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
warn_all(err, &o.warnings);
warn_all(err, &missing_font_warnings(&o.document));
let ws = files::save(&o.document, output, a.export.format.as_deref(), &opts, None)?;
warn_all(err, &ws);
Ok(())
}
pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result {
let file = &a.file;
let mut h = Headless::trusted_local();
let opened = h.open(file)?;
let mut doc = h.inspect(None)?;
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(&st.doc).into_iter().map(Value::String));
}
doc["warnings"] = Value::Array(warnings);
doc["file"] = json!(file.to_string_lossy());
// Drop per-session noise.
if let Value::Object(m) = &mut doc {
for k in ["history", "canUndo", "canRedo", "revision"] {
m.remove(k);
}
}
print_json(out, &doc, if a.compact { JsonStyle::Compact } else { JsonStyle::Pretty })
}
/// `(id, params)` pairs from repeated `--cmd`/`--params` flags.
fn command_list(a: &RunArgs) -> Result<Vec<(String, Value)>> {
let mut cmds: Vec<(String, Value)> = Vec::new();
for step in &a.steps {
match step {
Step::Cmd(id) => cmds.push((id.clone(), json!({}))),
Step::Params(p) => cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?.1 = p.clone(),
}
}
Ok(cmds)
}
pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let opts = ExportOptions::from(&a.export);
let mut h = Headless::trusted_local();
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
// clap's `source` group gives exactly one of the two, and its `work` group a --cmd or --out.
if let Some(new) = &a.new {
h.command_run("file.new", new.clone()).map_err(CliError::New)?;
} else if let Some(file) = &a.file {
// Refused before the file is read or any command runs.
if let Some(out) = &a.out {
refuse_overwriting_input(a.in_place, file, out)?;
}
let o = h.open(file)?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
}
let cmds = command_list(a)?;
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(CliError::NestedFailure { id, message });
}
let new_warnings: Vec<String> =
h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &new_warnings);
let mut result = json!({"command": id, "result": r});
if !new_warnings.is_empty() {
result["warnings"] = json!(new_warnings);
}
print_json(out, &result, JsonStyle::Compact)?;
}
// A `run` with only `--out` still reports missing fonts before exporting.
let remaining: Vec<String> =
h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &remaining);
if let Some(o) = a.out.as_deref() {
let r = h.save(None, Some(o), a.export.format.as_deref(), &opts)?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
}
#[cfg(test)]
mod missing_font_warning_tests {
use super::*;
use crate::{
batch::is_input,
test_support::{Invoke, invoke},
};
#[test]
fn missing_typeface_is_reported_by_run_info_and_convert() {
let family = "DefinitelyMissingPhotoCraftTypeface1251";
let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("missing.pcraft");
let png = folder.join("missing.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string();
let Invoke { code, stdout, stderr } = invoke(&[
"run",
"--new",
r#"{"width":256,"height":96,"background":"white"}"#,
"--cmd",
"type.create",
"--params",
&params,
"--cmd",
"type.resolveMissingFonts",
"--out",
&native_str,
]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning");
let commands: Vec<Value> = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect();
assert_eq!(commands.len(), 2, "{stdout}");
assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported");
let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}");
assert!(png.is_file());
// Even with no commands, run --out checks the opened document before export.
let Invoke { code, stdout, stderr } = invoke(&["run", &native_str, "--out", &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.is_empty());
assert_eq!(stderr.matches(family).count(), 1);
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn convert_reads_and_writes_openraster() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let path = |name: &str| folder.join(name).to_string_lossy().to_string();
let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png"));
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]);
assert_eq!(code, 0, "{stderr}");
let Invoke { code, stderr, .. } = invoke(&["convert", &native, &ora]);
assert_eq!(code, 0, "{stderr}");
let Invoke { code, stdout, stderr } = invoke(&["info", &ora, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}");
let Invoke { code, stderr, .. } = invoke(&["convert", &ora, &png]);
assert_eq!(code, 0, "{stderr}");
assert!(is_input(std::path::Path::new(&ora)));
// Krita documents are batch inputs too (import-only).
for name in ["sketch.kra", "old.KRZ"] {
std::fs::write(folder.join(name), b"").unwrap();
assert!(is_input(&folder.join(name)), "{name}");
}
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn no_type_layers_do_not_produce_missing_font_warnings() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("blank.pcraft");
let png = folder.join("blank.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '")));
let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
}
+42
View File
@@ -0,0 +1,42 @@
use std::path::PathBuf;
use photocraft_automation::AutomationError;
use crate::actions::ActionsError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
#[derive(Debug, thiserror::Error)]
pub(crate) enum CliError {
#[error(transparent)]
Automation(#[from] AutomationError),
#[error(transparent)]
Engine(#[from] photocraft_engine::EngineError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Actions(#[from] ActionsError),
#[error("{}: {source}", path.display())]
ReadDir { path: PathBuf, source: std::io::Error },
#[error("--params must follow a --cmd")]
ParamsBeforeCmd,
#[error("--new: {0}")]
New(#[source] AutomationError),
#[error("`{id}`: {source}")]
Command { id: String, source: AutomationError },
/// A step inside an action the command played failed ([`photocraft_engine::actions_cmds::nested_failure`]).
#[error("`{id}`: {message}")]
NestedFailure { id: String, message: String },
#[error("{} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())]
OutIsInput(PathBuf),
#[error("--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", .0.display())]
OutIsIn(PathBuf),
/// Another input already wrote this output ([`photocraft_engine::file_cmds::OutputClaims::check`]).
#[error("{0}")]
OutputClaimed(String),
#[error("{0} file(s) failed")]
FilesFailed(usize),
}
pub(crate) type Result<T = (), E = CliError> = std::result::Result<T, E>;
+57 -850
View File
@@ -3,234 +3,63 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security};
use photocraft_engine::actions_cmds::Action;
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
use clap::CommandFactory;
pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
mod actions;
mod args;
mod automation;
mod batch;
mod document;
mod error;
mod output;
USAGE:
photocraft-cli convert <in> <out> [--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>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
An <out> that is the <in> file is refused, as the result would replace the original;
--in-place allows it.
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data);
--tiff-compression compresses it (none, the default; lzw; zip).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
--jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized
(Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so
only Gray and CMYK shrink) or progressive.
photocraft-cli info <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>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <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,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
when the set holds more than one) with the whole set loaded, so its steps can play other actions
of the set. An --out folder that is the --in folder is refused, as the results would replace
the originals; --in-place allows it.
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
Run a droplet (File › Automate › Create Droplet) on images and folders.
photocraft-cli commands [--json] [--filter <text>]
List the engine command registry.
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>`).
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).
pub use actions::{ActionsError, Steps, load_actions, load_actions_file, parse_actions};
use args::{Command, parse};
use automation::{commands, mcp, serve};
use batch::{batch, droplet};
use document::{convert, info, run_cmds};
pub(crate) use error::{CliError, Result};
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
an error.
";
struct Args {
positional: Vec<String>,
flags: Vec<(String, Option<String>)>,
/// The command line's clap definition, for generating shell completions and man pages
/// (`examples/man-and-completions.rs`).
pub fn command() -> clap::Command {
args::Cli::command()
}
/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag
/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423).
struct Subcommand {
name: &'static str,
values: &'static [&'static str],
bare: &'static [&'static str],
run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R,
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &[
"--new",
"--cmd",
"--params",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &[
"--actions",
"--action",
"--in",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
Subcommand {
name: "mcp",
values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, _| mcp(a),
},
Subcommand {
name: "serve",
values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, err| serve(a, err),
},
];
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name);
let mut i = 0;
while let Some(s) = args.get(i) {
if s == "--help" || s == "-h" {
return Ok(None);
}
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
if sub.bare.contains(&k) {
return Err(format!("{k} takes no value"));
}
if !sub.values.contains(&k) {
return Err(unknown(k));
}
a.flags.push((k.to_owned(), Some(v.to_owned())));
} else if sub.values.contains(&s.as_str()) {
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
a.flags.push((s.clone(), Some(v.clone())));
i += 1;
} else if sub.bare.contains(&s.as_str()) {
a.flags.push((s.clone(), None));
} else if s.starts_with("--") {
return Err(unknown(s));
} else {
a.positional.push(s.clone());
}
i += 1;
}
Ok(Some(a))
}
impl Args {
fn get(&self, k: &str) -> Option<&str> {
self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref())
}
fn has(&self, k: &str) -> bool {
self.flags.iter().any(|(f, _)| f == k)
}
}
type R = Result<(), String>;
/// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error
/// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts
/// the process on one (issue #1108).
pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let mut strings = Vec::with_capacity(args.len());
for (i, a) in args.iter().enumerate() {
match a.clone().into_string() {
Ok(s) => strings.push(s),
Err(raw) => {
let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{USAGE}", i + 1, raw.to_string_lossy());
return 2;
}
}
}
run(&strings, out, err)
}
/// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage).
pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let Some(cmd) = args.first() else {
let _ = write!(err, "{USAGE}");
return 2;
};
match cmd.as_str() {
"-h" | "--help" | "help" => {
let _ = write!(out, "{USAGE}");
return 0;
}
"--version" | "version" => {
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
return 0;
}
_ => {}
}
let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else {
let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}");
return 2;
};
let parsed = match parse(sub, &args[1..]) {
Ok(Some(p)) => p,
// `<subcommand> --help`: usage, without reading inputs or starting a server.
Ok(None) => {
let _ = write!(out, "{USAGE}");
return 0;
}
/// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok,
/// 1 failure, 2 usage). clap reads the arguments as they are: a path that is not valid Unicode (a
/// Latin-1 file name on Linux) is taken, and one where text is expected is a usage error (exit 2).
/// Never a panic, as `std::env::args()` aborts on one (issue #1108).
pub fn run<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
// Help and `--version` (`help`, `-h`, `<subcommand> --help`, `-V`) come back as errors too,
// for `out`, exit 0: the subcommand never runs.
let cli = match parse(&args) {
Ok(cli) => cli,
Err(e) => {
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
return 2;
let to: &mut dyn Write = if e.use_stderr() { err } else { out };
let _ = write!(to, "{}", e.render());
return e.exit_code();
}
};
match (sub.run)(&parsed, out, err) {
let result = match &cli.command {
Command::Convert(a) => convert(a, out, err),
Command::Info(a) => info(a, out),
Command::Run(a) => run_cmds(a, out, err),
Command::Batch(a) => batch(a, out, err),
Command::Droplet(a) => droplet(a, out, err),
Command::Commands(a) => commands(a, out),
Command::Mcp(a) => mcp(a, err),
Command::Serve(a) => serve(a, err),
};
match result {
Ok(()) => 0,
Err(e) => {
let _ = writeln!(err, "error: {e}");
@@ -239,640 +68,18 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
}
}
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
if let Some(q) = a.get("--quality") {
let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
o.encode.jpeg_quality = q;
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
o.encode.webp_quality = q;
o.encode.webp_lossless = false;
}
if let Some(b) = a.get("--tga-bits") {
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
}
if let Some(b) = a.get("--dpx-bits") {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
if let Some(c) = a.get("--tiff-compression") {
o.encode.tiff_compression = match c {
"none" => photocraft_codecs::TiffCompression::None,
"lzw" => photocraft_codecs::TiffCompression::Lzw,
"zip" => photocraft_codecs::TiffCompression::Deflate,
_ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")),
};
}
if let Some(f) = a.get("--jpeg-format") {
o.encode.jpeg_format =
photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?;
}
if let Some(c) = a.get("--dds-compression") {
o.encode.dds.compression =
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
}
o.encode.dds.mipmaps = a.has("--dds-mipmaps");
Ok(o)
}
fn warn_all(err: &mut dyn Write, ws: &[String]) {
for w in ws {
let _ = writeln!(err, "warning: {w}");
}
}
/// The engine knows which type families aren't installed. Warn before an export
/// uses a fallback face rather than silently changing the appearance of the text.
fn missing_font_warnings(fonts: Vec<String>) -> Vec<String> {
fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect()
}
fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new))
.map_err(|error| error.to_string())
}
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
/// against mistakes, it is not a security boundary.
fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R {
if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) {
return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it"));
}
Ok(())
}
fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let opts = export_opts(a)?;
refuse_overwriting_input(a, input, output)?;
let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
warn_all(err, &o.warnings);
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
}
fn print_json(out: &mut dyn Write, v: &Value, compact: bool) -> R {
let s = if compact { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) }.map_err(|e| e.to_string())?;
writeln!(out, "{s}").map_err(|e| e.to_string())
}
fn info(a: &Args, out: &mut dyn Write) -> R {
let [file] = a.positional.as_slice() else {
return Err("info needs <file>".into());
};
let mut h = Headless::trusted_local();
let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let mut doc = h.inspect(None).map_err(|e| e.to_string())?;
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String));
}
doc["warnings"] = Value::Array(warnings);
doc["file"] = json!(file);
// Drop per-session noise.
if let Value::Object(m) = &mut doc {
for k in ["history", "canUndo", "canRedo", "revision"] {
m.remove(k);
}
}
print_json(out, &doc, a.has("--compact"))
}
/// `(id, params)` pairs from repeated `--cmd`/`--params` flags.
fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
let mut cmds: Vec<(String, Value)> = Vec::new();
for (k, v) in &a.flags {
match (k.as_str(), v) {
("--cmd", Some(id)) => cmds.push((id.clone(), json!({}))),
("--params", Some(p)) => {
let last = cmds.last_mut().ok_or("--params must follow a --cmd")?;
last.1 = serde_json::from_str(p).map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?;
}
_ => {}
}
}
Ok(cmds)
}
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
reported_fonts.extend(ws.iter().cloned());
warn_all(err, &ws);
reported_fonts.extend(ws);
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?;
}
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
}
let cmds = command_list(a)?;
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
.collect();
warn_all(err, &new_warnings);
let mut result = json!({"command": id, "result": r});
if !new_warnings.is_empty() {
result["warnings"] = json!(new_warnings);
}
print_json(out, &result, true)?;
}
// A `run` with only `--out` still reports missing fonts before exporting.
let remaining: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
.collect();
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
}
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
steps_of(&v)
}
fn steps_of(v: &Value) -> Result<Steps, String> {
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string())
}
/// Engine commands to run in order: `(id, params)`.
pub type Steps = Vec<(String, Value)>;
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
let list = v.get("actions").unwrap_or(&v);
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
if !is_set {
if name.is_some() {
return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into());
}
return Ok((steps_of(&v)?, Vec::new()));
}
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?;
let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, set.as_slice()) {
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?,
(None, [only]) => only,
(None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action <name> ({})", set.len(), names())),
};
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => {
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
}
(None, [only]) => only,
(None, []) => return Err(format!("{path}: the set holds no actions")),
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
fn strip_shell_steps(action: &mut Action) {
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
if shell_save_command(&id) || shell_view_command(&id) {
continue;
}
if action.disabled.contains(&i) {
disabled.insert(steps.len());
}
if action.dialogs.contains(&i) {
dialogs.insert(steps.len());
}
steps.push((id, p));
}
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
}
fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
set.iter_mut().for_each(strip_shell_steps);
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
if !a.has("--in-place") && same_dir(&in_dir, &out_dir) {
return Err(format!(
"--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them",
out_dir.display()
));
}
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
let mut inputs: Vec<PathBuf> =
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let r = (|| -> Result<Vec<String>, String> {
written.check(&target_text)?;
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
h.open(input).map_err(|e| e.to_string())?;
for (id, p) in &actions {
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
continue;
}
let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
return Err(format!("`{id}`: {e}"));
}
}
let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
})();
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
Err(e) => {
failed += 1;
let _ = writeln!(err, "FAIL {}: {e}", input.display());
}
}
}
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
if failed > 0 { Err(format!("{failed} file(s) failed")) } else { Ok(()) }
}
fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let (file, inputs) = a.positional.split_first().ok_or("droplet needs <file.pcdroplet> and inputs")?;
if inputs.is_empty() {
return Err("droplet needs at least one input file or folder".into());
}
let mut p = json!({"droplet": file, "input": inputs});
if let Some(o) = a.get("--out") {
p["output"] = json!(o);
}
let mut s = photocraft_engine::Session::new();
let r = s.execute("file.automate.runDroplet", p).map_err(|e| e.to_string())?;
for f in r["files"].as_array().into_iter().flatten() {
let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default());
}
let errors = r["errors"].as_array().cloned().unwrap_or_default();
for e in &errors {
let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default());
}
if errors.is_empty() { Ok(()) } else { Err(format!("{} file(s) failed", errors.len())) }
}
fn commands(a: &Args, out: &mut dyn Write) -> R {
let h = Headless::new();
let list = h.command_list();
let needle = a.get("--filter").map(str::to_lowercase);
let items: Vec<&Value> = list
.as_array()
.map(|v| v.iter().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.collect();
if a.has("--json") {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
}
for c in items {
let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" > ")).unwrap_or_default();
writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu).map_err(|e| e.to_string())?;
}
Ok(())
}
fn serve(a: &Args, err: &mut dyn Write) -> R {
use std::sync::{Arc, Mutex};
let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?)));
match a.get("--port") {
Some(port) => {
let port: u16 = port.parse().map_err(|_| format!("bad --port `{port}`"))?;
let addr = format!("127.0.0.1:{port}");
let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from));
let token = security::server_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?;
if let Some(path) = &token_file {
let _ = writeln!(err, "photocraft-cli control token file: {}", path.display());
} else if supplied.is_none() {
let _ = writeln!(err, "photocraft-cli control token: {token}");
}
photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| {
let _ = writeln!(err, "photocraft-cli serving on {local}");
})
.map_err(|e| e.to_string())
}
None => {
let stdin = std::io::stdin();
photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
}
}
}
fn mcp(a: &Args) -> R {
let server = match a.get("--bridge") {
Some(addr) => {
let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from));
let token = security::client_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?;
PhotocraftMcp::bridge(addr, &token).map_err(|e| e.to_string())?
}
None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?),
};
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(server.serve_stdio()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod missing_font_warning_tests {
use super::*;
fn invoke(args: &[&str]) -> (i32, String, String) {
let mut output = Vec::new();
let mut errors = Vec::new();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = run(&args, &mut output, &mut errors);
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
pub(crate) mod test_support {
/// What [`crate::run`] returned and wrote.
pub(crate) struct Invoke {
pub(crate) code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
}
#[test]
fn missing_typeface_is_reported_by_run_info_and_convert() {
let family = "DefinitelyMissingPhotoCraftTypeface1251";
let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("missing.pcraft");
let png = folder.join("missing.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string();
let (code, stdout, stderr) = invoke(&[
"run",
"--new",
r#"{"width":256,"height":96,"background":"white"}"#,
"--cmd",
"type.create",
"--params",
&params,
"--cmd",
"type.resolveMissingFonts",
"--out",
&native_str,
]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning");
let commands: Vec<Value> = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect();
assert_eq!(commands.len(), 2, "{stdout}");
assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported");
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}");
assert!(png.is_file());
// Even with no commands, run --out checks the opened document before export.
let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.is_empty());
assert_eq!(stderr.matches(family).count(), 1);
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn convert_reads_and_writes_openraster() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let path = |name: &str| folder.join(name).to_string_lossy().to_string();
let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png"));
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]);
assert_eq!(code, 0, "{stderr}");
let (code, _, stderr) = invoke(&["convert", &native, &ora]);
assert_eq!(code, 0, "{stderr}");
let (code, stdout, stderr) = invoke(&["info", &ora, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}");
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
assert_eq!(code, 0, "{stderr}");
assert!(is_input(std::path::Path::new(&ora)));
// Krita documents are batch inputs too (import-only).
for name in ["sketch.kra", "old.KRZ"] {
std::fs::write(folder.join(name), b"").unwrap();
assert!(is_input(&folder.join(name)), "{name}");
}
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn no_type_layers_do_not_produce_missing_font_warnings() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("blank.pcraft");
let png = folder.join("blank.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '")));
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
}
#[cfg(test)]
mod batch_action_set_tests {
use super::*;
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut output, mut errors) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = run(&args, &mut output, &mut errors);
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
}
/// A batched action that plays another action of its set finds it in every file's session; a
/// failure inside the called action fails the file, and a self-call stops instead of looping.
#[test]
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
for name in ["a", "b"] {
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
assert_eq!(code, 0, "{stderr}");
}
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
let (code, stdout, stderr) = batch("Outer");
assert_eq!(code, 0, "{stdout}{stderr}");
for name in ["a", "b"] {
let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
}
for action in ["Calls bad", "Loop"] {
let (code, stdout, stderr) = batch(action);
assert_ne!(code, 0, "{stdout}");
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Save and view steps recorded inside a batched action set are skipped, also in a called
/// action, and unchecked steps stay unchecked once they are gone (#6860).
#[test]
fn batch_skips_save_and_view_steps_inside_an_action_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
{"name": "Rotate", "steps": [
["file.save", {}],
["image.imageRotation.90cw", {}],
["view.fitOnScreen", {}],
["image.imageRotation.90cw", {}],
["file.saveAs", {}],
], "disabled": [3], "dialogs": [1]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
for action in ["Outer", "Rotate"] {
let (code, stdout, stderr) =
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
assert_eq!(code, 0, "{stdout}{stderr}");
let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
}
let _ = std::fs::remove_dir_all(&dir);
pub(crate) fn invoke(args: &[&str]) -> Invoke {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = crate::run(args, &mut stdout, &mut stderr);
Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
}
}
+3 -4
View File
@@ -1,9 +1,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a
// usage error, not a panic before anything is printed (issue #1108).
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let code = photocraft_cli::run_os(&args, &mut std::io::stdout(), &mut std::io::stderr());
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap,
// not panic before anything is printed (issue #1108).
let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
std::process::exit(code);
}
+51
View File
@@ -0,0 +1,51 @@
use photocraft_io::ExportOptions;
use serde_json::Value;
use std::io::Write;
use crate::{Result, args::ExportArgs};
/// The `--format` family's options. A bad value never gets here: clap refused it.
impl From<&ExportArgs> for ExportOptions {
fn from(a: &ExportArgs) -> Self {
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, ..Default::default() };
if let Some(q) = a.quality {
o.encode.jpeg_quality = q;
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
o.encode.webp_quality = q;
o.encode.webp_lossless = false;
}
o.encode.dpx_bits = a.dpx_bits;
if let Some(c) = a.tiff_compression {
o.encode.tiff_compression = c;
}
if let Some(f) = a.jpeg_format {
o.encode.jpeg_format = f;
}
if let Some(c) = a.dds_compression {
o.encode.dds.compression = c;
}
o.encode.dds.mipmaps = a.dds_mipmaps;
o
}
}
pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) {
for w in ws {
let _ = writeln!(err, "warning: {w}");
}
}
/// How [`print_json`] lays the JSON out.
pub(crate) enum JsonStyle {
Pretty,
Compact,
}
pub(crate) fn print_json(out: &mut dyn Write, v: &Value, style: JsonStyle) -> Result {
let s = match style {
JsonStyle::Pretty => serde_json::to_string_pretty(v),
JsonStyle::Compact => serde_json::to_string(v),
}?;
Ok(writeln!(out, "{s}")?)
}
@@ -6,10 +6,17 @@ use std::process::{Command, Stdio};
use serde_json::{Value, json};
/// The binary, without the variables `mcp` and `serve` fall back to: a test that wants one sets it.
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
let mut cmd = Command::new(env!("CARGO_BIN_EXE_photocraft-cli"));
for var in ENV_VARS {
cmd.env_remove(var);
}
cmd
}
const ENV_VARS: [&str; 3] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE"];
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
@@ -23,23 +30,33 @@ fn write_png(path: &Path, w: u32, h: u32, seed: u8) {
std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
}
fn ok(cmd: &mut Command) -> (String, String) {
/// What a successful run printed.
struct Printed {
stdout: String,
stderr: String,
}
fn ok(cmd: &mut Command) -> Printed {
let o = cmd.output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
(out, err)
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", o.status);
Printed { stdout, stderr }
}
#[test]
fn usage_and_unknown_command() {
let o = bin().output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("USAGE"));
assert!(String::from_utf8_lossy(&o.stderr).contains("convert"));
let o = bin().arg("frobnicate").output().unwrap();
assert_eq!(o.status.code(), Some(2));
let (out, _) = ok(bin().arg("--help"));
// `help` names a subcommand or nothing: anything else is a usage error too.
let o = bin().args(["help", "frobnicate"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(!o.stderr.is_empty());
let out = ok(bin().arg("--help")).stdout;
assert!(out.contains("convert"));
let (out, _) = ok(bin().arg("--version"));
let out = ok(bin().arg("--version")).stdout;
assert!(out.starts_with("photocraft-cli "));
}
@@ -53,9 +70,23 @@ fn every_subcommand_answers_help() {
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
assert!(out.contains(&format!("photocraft-cli {sub}")), "{sub} {help}: {out}");
}
}
// Help where a flag's value should be is still help, not a missing value or a file named `-h`.
// So is help after another mistake.
for args in [
&["run", "x.png", "--out", "--help"][..],
&["commands", "--filter", "-h"],
&["mcp", "--bridge", "-h"],
&["serve", "--port", "--help"],
&["mcp", "stray.psd", "--help"],
&["serve", "--port", "abc", "--port", "1", "-h"],
] {
let o = bin().current_dir(&d).args(args).stdin(Stdio::null()).output().unwrap();
assert_eq!(o.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
assert!(String::from_utf8_lossy(&o.stdout).contains(&format!("photocraft-cli {}", args[0])), "{args:?}");
}
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
}
@@ -73,7 +104,7 @@ fn unknown_flags_are_usage_errors() {
let o = cmd.output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
assert!(err.contains(flag), "{err}");
};
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
@@ -86,13 +117,29 @@ fn unknown_flags_are_usage_errors() {
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json"));
// A flag can't be a value: a missing value is reported, not filled with the next flag.
let o = bin()
.args(["batch", "--actions"])
.arg(&actions)
.arg("--in")
.arg(&input)
.arg("--out")
.arg(&out_dir)
.args(["--format", "--quality", "80"])
.output()
.unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("a value is required for '--format"));
assert!(!out_dir.exists(), "nothing written");
// `--flag=value` still passes a value that starts with `--`.
ok(bin().args(["commands", "--filter=--json"]));
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
let out = ok(bin().args(["commands", "--json", "--filter=gaussian"])).stdout;
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
for needle in ["ellipse", "marquee"] {
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
let out = ok(bin().args(["commands", "--filter", needle])).stdout;
assert!(out.contains("select.rect"), "{needle}: {out}");
}
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
@@ -101,10 +148,83 @@ fn unknown_flags_are_usage_errors() {
"80",
]));
assert!(out_dir.join("a.jpg").exists());
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
let out = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact")).stdout;
assert!(!out.trim().contains('\n'), "compact JSON is one line");
}
fn server_usage_error(args: &[&str], expected: &str) {
let o = bin().args(args).env_remove("PHOTOCRAFT_CONTROL_TOKEN").env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE").stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{args:?}: {err}");
assert!(o.stdout.is_empty(), "usage errors must leave protocol stdout empty: {args:?}");
assert!(err.contains(expected), "{args:?}: expected {expected:?} in {err}");
}
#[test]
fn server_modes_reject_ignored_or_conflicting_options() {
for sub in ["mcp", "serve"] {
server_usage_error(&[sub, "stray.psd"], "unexpected argument 'stray.psd'");
for flag in ["--control-token", "--control-token-file"] {
server_usage_error(&[sub, flag, "unused"], if sub == "mcp" { "requires --bridge" } else { "requires --port" });
}
for flag in ["--automation-read-root", "--automation-write-root"] {
server_usage_error(&[sub, flag, ".", flag, "."], "cannot be used multiple times");
server_usage_error(&[sub, &format!("{flag}=")], &format!("a value is required for '{flag} "));
server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("a value is required for '{flag} "));
}
let transport = if sub == "mcp" { "--bridge" } else { "--port" };
for flag in [transport, "--control-token", "--control-token-file"] {
// Valid values, so the repeat is what is refused (a transport value is parsed first).
server_usage_error(&[sub, flag, "7878", &format!("{flag}=7879")], "cannot be used multiple times");
server_usage_error(&[sub, flag, ""], &format!("for '{flag} "));
}
server_usage_error(&[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"], "cannot be used with");
}
for flag in ["--automation-read-root", "--automation-write-root"] {
server_usage_error(&["mcp", "--bridge", "127.0.0.1:7878", flag, "."], "configure automation roots on the desktop app");
}
}
/// Each launch line would, before validation, have started a TCP server and written the token file.
#[test]
fn server_validation_does_not_consume_stdio_or_create_files() {
let d = tmp("server-validation");
let token = d.join("must-not-be-created.token");
let other = d.join("other.token");
let token_flag = format!("--control-token-file={}", token.display());
let other_flag = format!("--control-token-file={}", other.display());
for tail in [&["stray"][..], &["--automation-read-root", ".", "--automation-read-root", "."], &["--port", "0"], &[&other_flag]] {
let mut child = bin()
.current_dir(&d)
.args(["serve", "--port", "0", &token_flag])
.args(tail)
.env_remove("PHOTOCRAFT_CONTROL_TOKEN")
.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// Keep stdin open: invalid launch arguments must fail before starting a server.
let input = child.stdin.take().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while child.try_wait().unwrap().is_none() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
if child.try_wait().unwrap().is_none() {
child.kill().unwrap();
child.wait().unwrap();
panic!("{tail:?}: invalid configuration started a server");
}
drop(input);
let o = child.wait_with_output().unwrap();
assert_eq!(o.status.code(), Some(2), "{tail:?}: {}", String::from_utf8_lossy(&o.stderr));
assert!(o.stdout.is_empty(), "{tail:?}");
assert!(!token.exists() && !other.exists(), "{tail:?}: validation must happen before creating credentials");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn convert_png_pcraft_png_is_lossless() {
let d = tmp("convert");
@@ -128,7 +248,7 @@ fn convert_errors() {
let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let o = bin().args(["convert", "only-one"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
std::fs::remove_dir_all(d).unwrap();
}
@@ -137,14 +257,14 @@ fn info_prints_layer_tree_json() {
let d = tmp("info");
let a = d.join("a.png");
write_png(&a, 10, 6, 3);
let (out, _) = ok(bin().arg("info").arg(&a));
let out = ok(bin().arg("info").arg(&a)).stdout;
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["width"], 10);
assert_eq!(v["height"], 6);
// An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop).
assert_eq!(v["layers"][0]["name"], "Layer 0");
assert!(v.get("history").is_none());
let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact"));
let out = ok(bin().arg("info").arg(&a).arg("--compact")).stdout;
assert_eq!(out.trim().lines().count(), 1);
std::fs::remove_dir_all(d).unwrap();
}
@@ -173,10 +293,10 @@ fn info_and_convert_report_decode_warnings() {
for (name, bytes, warning, export) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let out = ok(bin().arg("info").arg(&file).arg("--compact")).stdout;
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
let err = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))).stderr;
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
assert_eq!(
err.trim(),
@@ -194,18 +314,19 @@ fn info_and_convert_report_decode_warnings() {
fn run_commands_and_save() {
let d = tmp("run");
let out_pc = d.join("r.pcraft");
let (out, _) = ok(bin()
let out = ok(bin()
.args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#])
.args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#])
.args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##])
.args(["--cmd", "document.inspect"])
.arg("--out")
.arg(&out_pc));
.arg(&out_pc))
.stdout;
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[2]["command"], "document.inspect");
assert_eq!(lines[2]["result"]["name"], "Run");
let (info, _) = ok(bin().arg("info").arg(&out_pc));
let info = ok(bin().arg("info").arg(&out_pc)).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
// Saving a native document gives the reopened bundle its output filename.
assert_eq!(v["name"], "r.pcraft");
@@ -246,13 +367,14 @@ fn run_stops_after_failed_action_without_exporting() {
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
let stdout = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
.arg(&out))
.stdout;
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
@@ -267,10 +389,13 @@ fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command"));
// Neither a --cmd nor --out, and JSON that doesn't parse, are usage errors naming the flag.
let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--cmd"));
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--new"));
// Params must be a JSON object, for `--new` and for each `--params`.
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
@@ -310,10 +435,10 @@ fn batch_applies_actions_to_directory() {
std::fs::write(inp.join("notes.txt"), "ignored").unwrap();
let actions = d.join("actions.json");
std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"]));
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])).stdout;
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
for n in ["one", "two"] {
let (info, _) = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft"))));
let info = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["layers"][0]["name"], "Batch");
}
@@ -347,7 +472,7 @@ fn batch_skips_recorded_view_and_save_steps() {
assert!(o.status.success(), "{name}: {stdout}\n{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}");
let output = d.join(name).join("a.png");
let (info, _) = ok(bin().arg("info").arg(&output));
let info = ok(bin().arg("info").arg(&output)).stdout;
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}");
assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input");
@@ -433,8 +558,8 @@ fn batch_format_with_leading_dot() {
}
}
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
/// and nothing is written.
/// #491: `--quality` outside 1-100 is a usage error for every subcommand, whatever the number's
/// size, and nothing is written.
#[test]
fn quality_outside_1_to_100_is_refused() {
let d = tmp("quality");
@@ -450,12 +575,12 @@ fn quality_outside_1_to_100_is_refused() {
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
for cmd in [&mut convert, &mut run] {
let o = cmd.output().unwrap();
assert_eq!(o.status.code(), Some(1), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
assert_eq!(o.status.code(), Some(2), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains("--quality"), "--quality {q}");
assert!(!out.exists(), "--quality {q} wrote a file");
}
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
assert_eq!(o.status.code(), Some(2), "batch --quality {q}");
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
}
// The documented range still encodes, lower quality making a smaller file.
@@ -471,7 +596,7 @@ fn quality_outside_1_to_100_is_refused() {
}
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is a usage error (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
@@ -488,7 +613,7 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
}
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
let info = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
@@ -498,15 +623,16 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
for bad in ["24", "0", "abc"] {
let out = d.join("bad.dpx");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--dpx-bits {bad}");
assert!(err.contains("--dpx-bits") && err.contains("expected 8, 10, 12 or 16"), "--dpx-bits {bad}: {err}");
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
}
}
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
/// isn't none, bc1, bc2 or bc3 is a usage error and nothing is written (#1327).
#[test]
fn dds_converts_both_ways_with_the_compression_asked_for() {
let d = tmp("dds");
@@ -531,14 +657,14 @@ fn dds_converts_both_ways_with_the_compression_asked_for() {
for bad in ["bc7", "dxt2", ""] {
let out = d.join("bad.dds");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
assert_eq!(o.status.code(), Some(2), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains("--dds-compression"), "--dds-compression {bad}");
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is a
/// usage error and nothing is written.
#[test]
fn tga_bits_choose_the_targa_depth() {
let d = tmp("tga-bits");
@@ -557,14 +683,55 @@ fn tga_bits_choose_the_targa_depth() {
for bad in ["16", "0", "abc", ""] {
let out = d.join("bad.tga");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--tga-bits {bad}");
assert!(err.contains("--tga-bits") && err.contains("expected 24 or 32"), "--tga-bits {bad}: {err}");
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
}
}
/// #7437: `run <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"])).stdout;
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
/// doesn't name is refused and nothing is written.
/// doesn't name is a usage error and nothing is written.
#[test]
fn jpeg_format_chooses_the_scans() {
let d = tmp("jpeg-format");
@@ -584,8 +751,9 @@ fn jpeg_format_chooses_the_scans() {
for bad in ["Progressive", "1", ""] {
let out = d.join("bad.jpg");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--jpeg-format {bad}");
assert!(err.contains("--jpeg-format") && err.contains("expected baseline, optimized or progressive"), "--jpeg-format {bad}: {err}");
assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
}
}
@@ -625,7 +793,7 @@ fn batch_refuses_to_write_over_its_inputs() {
}
}
// A separate folder runs as before, leaving the originals alone.
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
let out = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"])).stdout;
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
assert_eq!(snapshot(), before);
// `--in-place` opts in: each result replaces its original.
@@ -667,16 +835,16 @@ fn convert_refuses_to_write_over_its_input() {
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
// `--in-place` opts in: the result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let Printed { stdout: out, .. } = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
#[test]
fn commands_listing() {
let (out, _) = ok(bin().arg("commands"));
let out = ok(bin().arg("commands")).stdout;
assert!(out.contains("file.new"));
let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"]));
let out = ok(bin().args(["commands", "--json", "--filter", "layer.new"])).stdout;
let v: Value = serde_json::from_str(&out).unwrap();
assert!(
v.as_array()
@@ -690,7 +858,7 @@ fn commands_listing() {
fn in_process_run_matches_binary() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = photocraft_cli::run(&["commands".into(), "--filter".into(), "file.new".into()], &mut out, &mut err);
let code = photocraft_cli::run(["commands", "--filter", "file.new"], &mut out, &mut err);
assert_eq!(code, 0);
assert!(String::from_utf8(out).unwrap().contains("file.new"));
}
@@ -700,20 +868,8 @@ fn mcp_stdio_handshake_and_tool_call() {
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut send = |v: Value| {
writeln!(stdin, "{v}").unwrap();
stdin.flush().unwrap();
};
let mut recv = |id: u64| -> Value {
loop {
let mut line = String::new();
assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed");
let v: Value = serde_json::from_str(&line).unwrap();
if v["id"] == id {
return v;
}
}
};
let mut send = |v: Value| mcp_send(&mut stdin, v);
let mut recv = |id: u64| mcp_recv(&mut stdout, id);
send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}));
let init = recv(1);
@@ -733,6 +889,61 @@ fn mcp_stdio_handshake_and_tool_call() {
let _ = child.wait();
}
/// Send one JSON-RPC line to an `mcp` child.
fn mcp_send(stdin: &mut impl Write, v: Value) {
writeln!(stdin, "{v}").unwrap();
stdin.flush().unwrap();
}
/// Read an `mcp` child's lines up to the reply to `id`.
fn mcp_recv(stdout: &mut impl BufRead, id: u64) -> Value {
loop {
let mut line = String::new();
assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed");
let v: Value = serde_json::from_str(&line).unwrap();
if v["id"] == id {
return v;
}
}
}
/// `mcp --bridge` takes a port or anything with a `:`; anything else is a usage error. A bare port
/// means 127.0.0.1, and the bridge connects at the first call, so with no app there the call fails
/// naming that address.
#[test]
fn mcp_bridge_takes_a_port_or_an_address() {
let o = bin().args(["mcp", "--bridge", "nope"]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains("invalid value 'nope' for '--bridge"), "{err}");
assert!(err.contains("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)"), "{err}");
// Nothing listens on port 1 in a test run. The token only has to be well-formed.
let token = "a".repeat(64);
let mut child = bin()
.args(["mcp", "--bridge", "1", "--control-token", token.as_str()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
mcp_send(
&mut stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
);
mcp_recv(&mut stdout, 1);
mcp_send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
mcp_send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"session_list","arguments":{}}}));
let r = mcp_recv(&mut stdout, 2);
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap_or_default().contains("127.0.0.1:1"), "{r}");
let _ = child.kill();
let _ = child.wait();
}
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
#[test]
fn batch_fails_inputs_that_share_an_output_name() {
@@ -761,7 +972,7 @@ fn batch_fails_inputs_that_share_an_output_name() {
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
// Keeping each file's format, every input gets its own output.
let out_dir = d.join("same");
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)).stdout;
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
}
@@ -777,7 +988,7 @@ fn droplet_runs_an_action_on_files() {
)
.unwrap();
let out_dir = d.join("out");
let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir));
let out = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir)).stdout;
assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}");
let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap();
assert_eq!(img.dimensions(), (4, 8));
@@ -789,7 +1000,7 @@ fn droplet_runs_an_action_on_files() {
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
let d = tmp("tiff-layers");
let layer_count = |path: &std::path::Path| -> usize {
let (info, _) = ok(bin().arg("info").arg(path));
let info = ok(bin().arg("info").arg(path)).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
v["layers"].as_array().unwrap().len()
};
@@ -815,19 +1026,38 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() {
}
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
/// `std::env::args()` before anything was printed. It is a usage error naming the argument.
/// `std::env::args()` before anything was printed. clap reads the arguments as they are: a path
/// takes one, and text (`--filter`) refuses it with a usage error.
#[cfg(unix)]
#[test]
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
fn a_non_unicode_path_is_taken_and_non_unicode_text_is_a_usage_error() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] {
let o = bin().args(&args).output().unwrap();
let d = tmp("non-unicode");
let name = OsStr::from_bytes(b"caf\xe9.png");
// No such file yet: the handler runs and fails on it (exit 1), no usage error.
for args in [vec![OsStr::new("info"), name], vec![OsStr::new("convert"), name, OsStr::new("out.png")]] {
let o = bin().current_dir(&d).args(&args).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}");
assert_eq!(o.status.code(), Some(1), "{args:?}: {err}");
assert!(!err.contains("panicked"), "{err}");
}
// Linux file systems take the name (macOS ones refuse it). `convert` reads it; `info` opens
// through the automation layer, which still needs UTF-8 paths, and says so (exit 1).
if cfg!(target_os = "linux") {
write_png(&d.join(name), 8, 4, 3);
ok(bin().current_dir(&d).arg("convert").arg(name).arg("out.png"));
assert!(d.join("out.png").is_file());
let o = bin().current_dir(&d).arg("info").arg(name).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "{err}");
assert!(err.contains("UTF-8"), "{err}");
}
let o = bin().args([OsStr::new("commands"), OsStr::new("--filter"), name]).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!((o.status.code(), o.stdout.is_empty()), (Some(2), true), "{err}");
assert!(err.contains("UTF-8") && !err.contains("panicked"), "{err}");
std::fs::remove_dir_all(d).unwrap();
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
@@ -850,7 +1080,7 @@ fn batch_plays_a_photoshop_actions_file() {
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
let err = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)).stderr;
assert!(out_dir.join("a.png").exists());
assert!(!err.contains("warning"), "{err}");
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
@@ -860,31 +1090,40 @@ fn batch_plays_a_photoshop_actions_file() {
assert!(!o.status.success());
}
/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut out, mut err) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = photocraft_cli::run(&args, &mut out, &mut err);
(code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
/// What `photocraft_cli::run` returned and wrote, in-process.
struct Invoke {
code: i32,
stdout: String,
stderr: String,
}
fn invoke(args: &[&str]) -> Invoke {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = photocraft_cli::run(args, &mut stdout, &mut stderr);
Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
}
const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
/// Pins top-level dispatch: help and the version go to stdout (exit 0), no arguments is a usage
/// error on stderr. There is no `version` subcommand.
#[test]
fn top_level_help_version_and_no_arguments() {
let (code, out, err) = invoke(&[]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[]);
assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
for help in ["--help", "-h", "help"] {
let (code, out, err) = invoke(&[help]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
}
for version in ["--version", "version"] {
let (code, out, err) = invoke(&[version]);
for version in ["--version", "-V"] {
let Invoke { code, stdout: out, stderr: err } = invoke(&[version]);
assert_eq!((code, err.as_str()), (0, ""), "{version}");
assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
assert!(out.starts_with("photocraft-cli ") && out.ends_with('\n') && out.lines().count() == 1, "{version}: {out}");
}
let (code, out, err) = invoke(&["frobnicate"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["version"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("version"), "{err}");
let Invoke { code, stdout: out, stderr: err } = invoke(&["frobnicate"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("frobnicate"), "{err}");
}
@@ -895,7 +1134,7 @@ fn top_level_help_version_and_no_arguments() {
fn every_subcommand_help_returns_on_stdout() {
for sub in SUBCOMMANDS {
for help in ["--help", "-h"] {
let (code, out, err) = invoke(&[sub, help]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[sub, help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
// `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
@@ -909,34 +1148,47 @@ fn every_subcommand_rejects_an_unknown_flag() {
for sub in SUBCOMMANDS {
for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
let (code, out, err) = invoke(&args);
let Invoke { code, stdout: out, stderr: err } = invoke(&args);
assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
assert!(err.contains("--fromat"), "{args:?}: {err}");
}
}
}
/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
/// `--flag=value`, a bare flag given a value, a value flag without one, repeated flags, and values
/// that start with `-`.
#[test]
fn flag_value_forms_and_repeats() {
let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::<Vec<_>>();
let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
let Invoke { code, stdout: eq, stderr: err } = invoke(&["commands", "--filter=file.new"]);
assert_eq!(code, 0, "{err}");
let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
let spaced = invoke(&["commands", "--filter", "file.new"]).stdout;
assert_eq!(eq, spaced);
assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
// The last of a repeated value flag wins, whichever order they come in.
let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
// A repeated flag is a usage error naming it, more likely a mistake than a choice.
for repeat in [&["commands", "--filter", "zzz-no-match", "--filter", "file.new"][..], &["commands", "--json", "--json"][..]] {
let Invoke { code, stdout: out, stderr: err } = invoke(repeat);
assert_eq!((code, out.is_empty()), (2, true), "{repeat:?}");
assert!(err.contains(repeat[1]) && err.contains("cannot be used multiple times"), "{repeat:?}: {err}");
}
let Invoke { code, stderr: err, .. } = invoke(&["convert", "a.png", "b.jpg", "--quality", "80", "--quality", "60"]);
assert_eq!(code, 2);
assert!(err.contains("--quality"), "{err}");
// A value starting with `-` needs `=`; without it the token is a flag, never the value. A
// negative number is still a value.
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter=-zzz-no-match"]);
assert_eq!((code, out.as_str()), (0, ""), "{err}");
for value in ["-zzz-no-match", "--json"] {
let Invoke { code, stdout: out, .. } = invoke(&["commands", "--filter", value]);
assert_eq!((code, out.is_empty()), (2, true), "--filter {value}");
}
let Invoke { code, stderr: err, .. } = invoke(&["commands", "--filter", "-1"]);
assert_eq!(code, 0, "{err}");
assert_eq!(out, eq);
let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
assert_eq!(code, 0, "{err}");
assert!(out.is_empty(), "{out}");
// A bare flag takes no value; a value flag needs one.
let (code, out, err) = invoke(&["commands", "--json=1"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--json=1"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--json"), "{err}");
let (code, out, err) = invoke(&["commands", "--filter"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--filter"), "{err}");
}
@@ -945,7 +1197,7 @@ fn flag_value_forms_and_repeats() {
/// `--params` ahead of every `--cmd` fails naming `--params`.
#[test]
fn run_pairs_params_with_the_preceding_cmd() {
let (code, out, err) = invoke(&[
let Invoke { code, stdout: out, stderr: err } = invoke(&[
"run",
"--new",
r#"{"width":8,"height":8}"#,
@@ -973,7 +1225,7 @@ fn run_pairs_params_with_the_preceding_cmd() {
// Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
// with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
assert_ne!(code, 0);
assert!(err.contains("--params"), "{err}");
}
@@ -992,3 +1244,180 @@ fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
assert_eq!(doc.resolution_dpi, 300.0);
std::fs::remove_dir_all(d).unwrap();
}
/// `--out=-image.png` writes `-image.png`; `--out -image.png` is a usage error and writes nothing.
#[test]
fn a_value_starting_with_a_dash_is_written_with_equals() {
let d = tmp("dash-value");
let o = bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out", "-image.png"]).output().unwrap();
assert_eq!(o.status.code(), Some(2), "{}", String::from_utf8_lossy(&o.stderr));
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0);
ok(bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out=-image.png"]));
assert!(d.join("-image.png").is_file());
std::fs::remove_dir_all(d).unwrap();
}
/// `run` takes exactly one of `<FILE>` or `--new`, and `mcp`/`serve` one of `--control-token` or
/// `--control-token-file`: anything else is a usage error naming the flags, before any work.
#[test]
fn either_or_flags_are_usage_errors() {
let Invoke { code, stderr: err, .. } = invoke(&["run", "a.png", "--new", "{}", "--cmd", "layer.new.layer"]);
assert_eq!(code, 2);
assert!(err.contains("--new") && err.contains("cannot be used with"), "{err}");
let Invoke { code, stderr: err, .. } = invoke(&["run", "--cmd", "layer.new.layer"]);
assert_eq!(code, 2);
assert!(err.contains("--new"), "{err}");
let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "{"]);
assert_eq!(code, 2);
assert!(err.contains("--params"), "{err}");
let token = "a".repeat(64);
for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] {
let o = bin().args(mode).args(["--control-token", token.as_str(), "--control-token-file", "token"]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{mode:?}: {err}");
assert!(err.contains("--control-token-file") && err.contains("cannot be used with"), "{mode:?}: {err}");
}
for port in ["70000", "x"] {
let o = bin().args(["serve", "--port", port]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--port {port}: {err}");
assert!(err.contains("--port"), "--port {port}: {err}");
}
}
/// How a run of the binary ended.
struct Exited {
code: Option<i32>,
stderr: String,
}
/// The binary run with `args` and one variable set, stdin at its end.
fn with_var(args: &[&str], var: &str, value: &str) -> Exited {
let o = bin().args(args).env(var, value).stdin(Stdio::null()).output().unwrap();
Exited { code: o.status.code(), stderr: String::from_utf8_lossy(&o.stderr).into_owned() }
}
/// A port nothing listens on, for a `serve` to take.
fn free_port() -> u16 {
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port()
}
/// `serve` on TCP never exits: read its stderr until it says it is serving (or its stderr closes),
/// then kill it.
fn serve_tcp_stderr(cmd: &mut Command) -> String {
use std::io::Read as _;
let mut child = cmd.stdin(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
// Read on a thread, so a `serve` that stays silent with stderr open fails the test after the
// deadline instead of hanging it.
let mut stderr = child.stderr.take().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
while let Ok(n) = stderr.read(&mut buf) {
if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut err = String::new();
while !err.contains("photocraft-cli serving on") {
match rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) {
Ok(chunk) => err.push_str(&String::from_utf8_lossy(&chunk)),
Err(_) => break,
}
}
let _ = child.kill();
let _ = child.wait();
err
}
/// #2491: `PHOTOCRAFT_CONTROL_PORT` turns `serve` from stdio into a TCP listener and `mcp` from a
/// headless engine into a bridge, and both say so on stderr. A flag wins, an empty value counts as
/// unset, and a bad value is a usage error naming the variable.
#[test]
fn control_port_env_switches_modes_with_a_notice_and_flags_win() {
let port = free_port();
let err = serve_tcp_stderr(bin().arg("serve").env("PHOTOCRAFT_CONTROL_PORT", port.to_string()));
assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio")), "{err}");
assert!(err.contains("unset it to serve stdio") && err.contains("photocraft-cli serving on"), "{err}");
// No token is given, so the bridge fails after the notice.
let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", &port.to_string()).stderr;
assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on 127.0.0.1:{port}")), "{err}");
assert!(err.contains("unset it to run headless"), "{err}");
// The flag wins, so the variable's bad value is never parsed.
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(port.to_string()).env("PHOTOCRAFT_CONTROL_PORT", "not-a-port"));
assert!(err.contains("photocraft-cli serving on") && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{err}");
// Empty or blank counts as unset: `serve` reads stdio (at its end, so it exits at once) and
// `mcp` stays headless.
// A root on the command line is the headless engine's: the variable can't switch to a bridge
// that would drop it.
for root in ["--automation-read-root", "--automation-write-root"] {
let Exited { code, stderr: err } = with_var(&["mcp", root, "."], "PHOTOCRAFT_CONTROL_PORT", &port.to_string());
assert_eq!(code, Some(2), "{root}: {err}");
assert!(err.contains(root) && err.contains("cannot be used with 'PHOTOCRAFT_CONTROL_PORT'") && !err.contains("bridging"), "{root}: {err}");
}
for empty in ["", " "] {
let Exited { code, stderr: err } = with_var(&["serve"], "PHOTOCRAFT_CONTROL_PORT", empty);
assert!(code == Some(0) && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{empty:?}: {err}");
let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", empty).stderr;
assert!(!err.contains("bridging"), "{empty:?}: {err}");
}
for (sub, bad) in [
("serve", "not-a-port"),
("serve", "70000"),
("mcp", "nope"),
("mcp", "70000"),
// `--bridge` takes host:port, the variable only a port.
("mcp", "127.0.0.1:7878"),
("mcp", ":7878"),
("mcp", "bad:port"),
] {
let Exited { code, stderr: err } = with_var(&[sub], "PHOTOCRAFT_CONTROL_PORT", bad);
assert_eq!(code, Some(2), "{sub} {bad}: {err}");
assert!(err.contains(&format!("invalid value '{bad}' for 'PHOTOCRAFT_CONTROL_PORT'")), "{sub} {bad}: {err}");
}
}
/// `--control-token` and `--control-token-file` fall back to `PHOTOCRAFT_CONTROL_TOKEN` and
/// `PHOTOCRAFT_CONTROL_TOKEN_FILE`. One from the environment and the other as a flag is the
/// either/or usage error, naming the variable, when a token is used: a stale exported pair doesn't
/// stop headless `mcp` or stdio `serve`. Help names the token's variable, never its value.
#[test]
fn control_token_env_vars_are_the_flags_fallbacks() {
let token = "a".repeat(64);
for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] {
let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token", token.as_str()][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN_FILE", "token");
assert_eq!(code, Some(2), "{mode:?}: {err}");
assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN_FILE") && err.contains("cannot be used with"), "{mode:?}: {err}");
let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token-file", "token"][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN", &token);
assert_eq!(code, Some(2), "{mode:?}: {err}");
assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN") && err.contains("cannot be used with") && !err.contains(&token), "{mode:?}: {err}");
}
for sub in ["mcp", "serve"] {
let o = bin().arg(sub).env("PHOTOCRAFT_CONTROL_TOKEN", &token).env("PHOTOCRAFT_CONTROL_TOKEN_FILE", "token").stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(!err.contains("cannot be used with"), "{sub}: {err}");
// stdio `serve` exits 0 at the end of its input; how headless `mcp` ends there isn't
// pinned, so it only must not be the usage error.
if sub == "serve" {
assert_eq!(o.status.code(), Some(0), "{sub}: {err}");
} else {
assert_ne!(o.status.code(), Some(2), "{sub}: {err}");
}
let o = bin().args([sub, "--help"]).env("PHOTOCRAFT_CONTROL_TOKEN", &token).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert!(out.contains("[env: PHOTOCRAFT_CONTROL_TOKEN]") && !out.contains(&token), "{sub}: {out}");
}
// The server takes the variable's token, so it neither generates nor prints one; an empty
// variable counts as unset.
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", &token));
assert!(err.contains("photocraft-cli serving on") && !err.contains("control token"), "{err}");
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", ""));
assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}");
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, linked into one test binary.
mod cli;
mod man_and_completions;
mod orientation;
@@ -0,0 +1,55 @@
//! The clap definition is consistent, and every shell's completions and every man page
//! (`examples/man-and-completions.rs`) can be generated from it.
use clap::ValueEnum;
use clap_complete::Shell;
#[test]
fn command_is_consistent() {
photocraft_cli::command().debug_assert();
}
#[test]
fn completions_generate_for_every_shell() {
for &shell in Shell::value_variants() {
let mut out = Vec::new();
clap_complete::generate(shell, &mut photocraft_cli::command(), "photocraft-cli", &mut out);
assert!(!out.is_empty(), "{shell}");
}
}
#[test]
fn man_pages_render_for_the_command_and_every_subcommand() {
let cmd = photocraft_cli::command();
for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) {
let mut out = Vec::new();
clap_mangen::Man::new(page.clone()).render(&mut out).unwrap();
assert!(!out.is_empty(), "{}", page.get_name());
}
}
/// The example's two `generate_to` calls, repeated here, write the five completion files under the
/// names `packaging/linux/package.sh` and `packaging/freebsd/package.sh` install, `photocraft-cli.1`
/// and a page per subcommand, and no pages for the `help` subcommand.
#[test]
fn the_example_writes_the_files_the_packages_install() {
let dir = std::env::temp_dir().join(format!("pc-cli-{}-man-and-completions", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut cmd = photocraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", &dir).unwrap();
}
clap_mangen::generate_to(photocraft_cli::command(), &dir).unwrap();
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
written.sort();
let completions = ["photocraft-cli.bash", "_photocraft-cli", "photocraft-cli.fish", "photocraft-cli.elv", "_photocraft-cli.ps1"];
let subcommands = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
let mut expected: Vec<String> = completions.map(String::from).into();
expected.push("photocraft-cli.1".into());
expected.extend(subcommands.iter().map(|s| format!("photocraft-cli-{s}.1")));
expected.sort();
assert_eq!(written, expected);
std::fs::remove_dir_all(&dir).unwrap();
}
+1 -1
View File
@@ -34,7 +34,7 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] }
[dev-dependencies]
serde_json = { workspace = true }
+14 -13
View File
@@ -17,12 +17,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .act, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign",
"afphoto", "afpub",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act", "ase", "af",
"afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -233,20 +233,21 @@ fn browser_languages() -> Vec<String> {
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default()
}
/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource
/// Integrity value), the browser rejects a body whose hash differs.
/// GET `url` (a relative path on the same origin) and read the whole body. With `integrity` (a
/// Subresource Integrity value), the browser rejects a body whose hash differs.
async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, String> {
use wasm_bindgen_futures::JsFuture;
if !served_fonts::is_relative_path(url) {
return Err("url must be a relative path".into());
}
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let request = match integrity {
Some(sri) => {
let init = web_sys::RequestInit::new();
init.set_integrity(sri);
window.fetch_with_str_and_init(url, &init)
}
None => window.fetch_with_str(url),
};
let init = web_sys::RequestInit::new();
init.set_mode(web_sys::RequestMode::SameOrigin);
if let Some(sri) = integrity {
init.set_integrity(sri);
}
let request = window.fetch_with_str_and_init(url, &init);
let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
+5
View File
@@ -33,6 +33,9 @@ eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11",
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -40,6 +43,8 @@ arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
ron = "0.12"
# Background release checks; HTTPS with Rust TLS, no async runtime.
ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
+340
View File
@@ -0,0 +1,340 @@
//! The desktop app's command line, as clap types (#7071): `photocraft --help` comes from these doc
//! comments, so the help and the parser can't disagree. Anything they don't declare is a usage
//! error naming it (exit status 2), so a typo such as `--safegpu` doesn't open the window and then
//! fail to open the flag as a file (#6431); so is a flag without its value, rather than a silent
//! fall back to a generated token or to no root (#6429), a repeated flag, and a bad
//! `--present-mode` or `--frame-latency`. A value never starts with `-` (write `--opt=-value`),
//! except for negative numbers, and a file whose name starts with `-` goes after `--`.
//!
//! The control and automation flags are `photocraft-automation`'s, shared with `photocraft-cli`.
//! Only the desktop app reads the automation roots from `PHOTOCRAFT_AUTOMATION_*_ROOT` (#1551).
use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;
use clap::builder::FalseyValueParser;
use clap::parser::ValueSource;
use clap::{Command, CommandFactory, FromArgMatches, Parser};
use eframe::wgpu::PresentMode;
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
use crate::presentation;
/// Opens PhotoCraft with the given files
#[derive(Parser)]
#[command(name = "photocraft", version = photocraft_engine::build_info::long_version_static(), after_help = ENVIRONMENT)]
pub(crate) struct LaunchArgs {
/// Files to open. On Windows and Linux they open as new tabs in the PhotoCraft already running.
/// Put `--` before a name that starts with `-`
pub(crate) files: Vec<PathBuf>,
/// Start with the CPU renderer for this launch (after a graphics driver crash)
#[arg(long)]
pub(crate) safe_gpu: bool,
/// macOS: keep the menus inside the window instead of the menu bar (the variable: 0, false, no
/// or off is off; empty is unset)
#[arg(long, env = "PHOTOCRAFT_IN_WINDOW_MENUS", value_parser = FalseyValueParser::new())]
pub(crate) in_window_menus: bool,
/// Windows and Linux: start a separate PhotoCraft instead of opening the files in the one
/// already running
#[arg(long)]
pub(crate) new_instance: bool,
/// How frames reach the display: fifo, autovsync or novsync (also immediate or mailbox).
/// Without it, PHOTOCRAFT_PRESENT_MODE
#[arg(long, value_name = "MODE", value_parser = present_mode)]
pub(crate) present_mode: Option<PresentMode>,
/// Frames queued for the display, 1 to 3. Without it, PHOTOCRAFT_FRAME_LATENCY
// allow_negative_numbers makes `--frame-latency -1` a bad value rather than an unexpected argument.
#[arg(long, value_name = "1-3", value_parser = frame_latency, allow_negative_numbers = true)]
pub(crate) frame_latency: Option<u32>,
/// Time each brush stroke's frames: stderr prints a summary per stroke, a CSV file path also
/// records every frame. Without it, PHOTOCRAFT_STROKE_TRACE
#[arg(long, value_name = "stderr|FILE.csv")]
pub(crate) stroke_trace: Option<String>,
/// Start the localhost JSON-lines control server on this port
#[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)]
pub(crate) control: Option<u16>,
#[command(flatten)]
pub(crate) auth: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
/// The per-process options given on the command line, set by [`parse`].
#[arg(skip)]
pub(crate) launch_options: Vec<PerProcess>,
}
/// The variables that aren't a flag's fallback, for the end of `--help`.
const ENVIRONMENT: &str = "\
Environment: PHOTOCRAFT_NEW_INSTANCE (anything but 0 or empty: as --new-instance), PHOTOCRAFT_CONFIG_DIR (the settings folder)";
/// An option that applies only to the process it's given to: a launch with any of them keeps its
/// own process instead of handing its files to the running one (`single_instance`). Shown as the
/// flag, or the variable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PerProcess {
AutomationReadRoot,
AutomationWriteRoot,
SafeGpu,
InWindowMenus,
PresentMode,
FrameLatency,
StrokeTrace,
/// PHOTOCRAFT_GPU_ADAPTER, when startup will act on it (`gpu_startup`). Not a flag.
GpuAdapterEnv,
}
impl PerProcess {
/// The flags, in the order a log line lists them. A flag's variable asks for the same thing, so
/// it counts too (#7242); the variables that aren't a flag's fallback are counted by
/// `single_instance::env_launch_options`.
pub(crate) const FLAGS: [PerProcess; 7] =
[Self::AutomationReadRoot, Self::AutomationWriteRoot, Self::SafeGpu, Self::InWindowMenus, Self::PresentMode, Self::FrameLatency, Self::StrokeTrace];
/// The flag's clap argument id.
fn id(self) -> Option<&'static str> {
Some(match self {
Self::AutomationReadRoot => "automation_read_root",
Self::AutomationWriteRoot => "automation_write_root",
Self::SafeGpu => "safe_gpu",
Self::InWindowMenus => "in_window_menus",
Self::PresentMode => "present_mode",
Self::FrameLatency => "frame_latency",
Self::StrokeTrace => "stroke_trace",
Self::GpuAdapterEnv => return None,
})
}
}
impl fmt::Display for PerProcess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::AutomationReadRoot => "--automation-read-root",
Self::AutomationWriteRoot => "--automation-write-root",
Self::SafeGpu => "--safe-gpu",
Self::InWindowMenus => "--in-window-menus",
Self::PresentMode => "--present-mode",
Self::FrameLatency => "--frame-latency",
Self::StrokeTrace => "--stroke-trace",
Self::GpuAdapterEnv => "PHOTOCRAFT_GPU_ADAPTER",
})
}
}
/// The files to open, as [`LaunchArgs::file_names`] sorts them.
#[derive(Debug, PartialEq)]
pub(crate) struct FileNames {
pub(crate) openable: Vec<String>,
/// Names that are not valid Unicode, shown lossily: reported, not opened.
pub(crate) unreadable: Vec<String>,
}
impl LaunchArgs {
/// The files to open, and those whose names are not valid Unicode (#1108): a file manager can
/// hand over a Latin-1 name on Linux. Those are reported in a notice once the app is up.
pub(crate) fn file_names(&self) -> FileNames {
let mut names = FileNames { openable: Vec::new(), unreadable: Vec::new() };
for f in &self.files {
match f.to_str() {
Some(s) => names.openable.push(s.to_owned()),
None => names.unreadable.push(f.to_string_lossy().into_owned()),
}
}
names
}
}
/// The command line, with the desktop app's variables for the automation roots. An empty or blank
/// variable counts as unset.
pub(crate) fn command() -> Command {
LaunchArgs::command()
.mut_arg("automation_read_root", |a| a.env("PHOTOCRAFT_AUTOMATION_READ_ROOT"))
.mut_arg("automation_write_root", |a| a.env("PHOTOCRAFT_AUTOMATION_WRITE_ROOT"))
.mut_args(blank_env_is_unset)
}
/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS
/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless
/// it comes after `--`. A bad value from a variable is a usage error naming the variable.
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
let mut options = true;
let args: Vec<OsString> = args
.into_iter()
.filter(|a| {
options &= a != "--";
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
})
.collect();
let argv = std::iter::once(OsString::from("photocraft")).chain(args.iter().cloned());
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
let mut launch = LaunchArgs::from_arg_matches(&matches)?;
// Only the control server reads a token, so a stale exported pair doesn't stop a launch.
if launch.control.is_some() {
conflict(&mut cmd, &matches, "control_token", "control_token_file")?;
}
// `PHOTOCRAFT_IN_WINDOW_MENUS=0` (or off, false, no) is a variable that turns the menus off.
let menus = launch.in_window_menus;
let given = |p: &PerProcess| match p.id().and_then(|id| matches.value_source(id)) {
Some(ValueSource::CommandLine) => true,
Some(ValueSource::EnvVariable) => *p != PerProcess::InWindowMenus || menus,
_ => false,
};
launch.launch_options = PerProcess::FLAGS.into_iter().filter(given).collect();
Ok(launch)
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
enum ValueError {
#[error("expected fifo, autovsync or novsync (or immediate, mailbox)")]
PresentMode,
#[error("expected 1, 2 or 3")]
FrameLatency,
}
fn present_mode(s: &str) -> Result<PresentMode, ValueError> {
presentation::present_mode(s).ok_or(ValueError::PresentMode)
}
fn frame_latency(s: &str) -> Result<u32, ValueError> {
presentation::frame_latency(s).ok_or(ValueError::FrameLatency)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
/// Parse without the variables, so one exported where the tests run can't change them (the
/// process tests in `tests/command_line_usage.rs` cover the variables).
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
}
fn usage_error(args: &[&str]) -> String {
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
e.to_string()
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, negative and empty values fail, naming the value (#701); a following
// flag is never taken as the value.
assert!(usage_error(&["--control", "78787"]).contains("invalid value '78787' for '--control <PORT>'"));
assert!(usage_error(&["--control", "-1"]).contains("invalid value '-1'"));
assert!(usage_error(&["--control="]).contains("invalid value '' for '--control <PORT>'"));
assert!(usage_error(&["--control", "--safe-gpu"]).contains("a value is required for '--control <PORT>'"));
}
#[test]
fn flags_without_a_value_are_usage_errors() {
// #6429: a missing value fails the launch, rather than falling back to a generated token
// or to no root. The tuning flags too, now (#7071).
for flag in [
"--control",
"--control-token",
"--control-token-file",
"--automation-read-root",
"--automation-write-root",
"--present-mode",
"--frame-latency",
"--stroke-trace",
] {
let err = usage_error(&[flag]);
assert!(err.contains(&format!("a value is required for '{flag} <")), "{flag}: {err}");
}
}
#[test]
fn bad_tuning_values_and_repeats_are_usage_errors() {
assert!(usage_error(&["--present-mode", "fast"]).contains("expected fifo, autovsync or novsync"));
for latency in ["0", "4", "-1", "one"] {
let err = usage_error(&["--frame-latency", latency]);
assert!(err.contains(&format!("invalid value '{latency}' for '--frame-latency <1-3>'")), "{err}");
}
assert!(usage_error(&["--safe-gpu", "--safe-gpu"]).contains("cannot be used multiple times"));
assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times"));
// A token and a token file are either/or for the control server, and ignored without it.
let pair = ["--control-token", "abcd", "--control-token-file", "/tmp/token"];
assert!(usage_error(&[&["--control", "1"][..], &pair[..]].concat()).contains("cannot be used with"));
assert!(parse_args(&pair).is_ok());
}
#[test]
fn values_are_parsed_and_per_process_options_recorded() {
let parsed = parse_args(&[
"--stroke-trace",
"stderr",
"--control",
"50494",
"--control-token",
"abcd",
"--automation-read-root",
"/r",
"--automation-write-root",
"/w",
"--safe-gpu",
"--in-window-menus",
"--new-instance",
"--present-mode",
"Mailbox",
"--frame-latency",
"1",
"photo.psd",
])
.unwrap();
assert_eq!(parsed.control, Some(50494));
assert_eq!(parsed.auth.control_token.as_deref(), Some("abcd"));
assert_eq!(parsed.auth.control_token_file, None);
assert_eq!(parsed.automation.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
assert_eq!(parsed.automation.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
assert!(parsed.safe_gpu && parsed.in_window_menus && parsed.new_instance);
assert_eq!(parsed.present_mode, Some(PresentMode::AutoNoVsync));
assert_eq!(parsed.frame_latency, Some(1));
assert_eq!(parsed.stroke_trace.as_deref(), Some("stderr"));
// In a fixed order: `single_instance` only lists them in a log line.
assert_eq!(parsed.launch_options, PerProcess::FLAGS);
assert_eq!(parsed.file_names(), FileNames { openable: vec!["photo.psd".to_string()], unreadable: vec![] });
let parsed = parse_args(&["--control-token-file", "/tmp/token", "--new-instance"]).unwrap();
assert_eq!(parsed.auth.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
assert!(parsed.launch_options.is_empty() && !parsed.in_window_menus, "{:?}", parsed.launch_options);
}
#[test]
fn version_stops_parsing() {
let e = parse_args(&["--version", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
assert_eq!(e.to_string(), format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let e = parse_args(&["-h", "--safegpu", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
assert!(usage_error(&["--safegpu", "--", "--help"]).contains("unexpected argument '--safegpu'"));
let parsed = parse_args(&["a.psd", "--", "--help", "-x"]).unwrap();
assert_eq!(parsed.file_names().openable, ["a.psd", "--help", "-x"]);
}
#[test]
fn a_finder_process_serial_number_is_dropped() {
let parsed = parse_args(&["-psn_0_12345", "a.psd"]).unwrap();
assert_eq!(parsed.file_names().openable, ["a.psd"]);
let parsed = parse_args(&["--", "-psn_x.psd"]).unwrap();
assert_eq!(parsed.file_names().openable, ["-psn_x.psd"]);
}
/// #1108: a name that isn't valid Unicode is reported, not opened; a token must be text.
#[cfg(unix)]
#[test]
fn non_unicode_file_names_are_reported_and_tokens_rejected() {
use std::os::unix::ffi::OsStringExt as _;
let latin1 = OsString::from_vec(b"caf\xe9.psd".to_vec());
let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone()]).unwrap();
assert_eq!(parsed.file_names(), FileNames { openable: vec![], unreadable: vec!["caf\u{fffd}.psd".to_string()] });
let e = parse(command().mut_args(|a| a.env(None::<&str>)), [OsString::from("--control-token"), latin1]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::InvalidUtf8, 2));
}
}
+40 -17
View File
@@ -91,27 +91,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -416,6 +425,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+29 -362
View File
@@ -1,14 +1,7 @@
//! PhotoCraft desktop app.
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [--] [files…]`
//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version).
//!
//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo
//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file
//! whose name starts with `-` goes after `--`.
//! Usage: `photocraft [options] [--] [files…]`; `photocraft --help` lists the options, which are
//! declared, with what makes a usage error (exit status 2), in `launch_args`.
//!
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
@@ -31,7 +24,8 @@
//! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods.
// Release builds on Windows are GUI-subsystem apps, so launching from the Start Menu or Explorer
// doesn't open a console window. (`--version` output then only shows when redirected.)
// doesn't open a console window. (`--help`, `--version` and usage errors then only show when
// redirected.)
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -49,6 +43,7 @@ mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "linux")]
mod icon_theme;
mod launch_args;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
@@ -74,6 +69,7 @@ mod single_instance;
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
mod updates;
mod window_frame;
use photocraft_engine::Session;
@@ -147,360 +143,30 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde
builder
}
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
/// names the value (issue #701): silently running with no control server leaves a launcher or
/// agent unable to tell a typo from a successful grant.
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
}
/// Process exit status for the collected usage errors (a bad `--control` /
/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a
/// command-line usage error), never 0.
fn usage_exit_code(errors: &[String]) -> Option<i32> {
if errors.is_empty() { None } else { Some(2) }
}
/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs.
const USAGE: &str = "\
Usage: photocraft [options] [--] [files…]
Opens PhotoCraft with the given files.
Options:
-h, --help Print this help and exit
--version Print the version, commit and build date and exit
--safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash)
--in-window-menus macOS: keep the menus inside the window instead of the menu bar
--new-instance Windows and Linux: start a separate PhotoCraft instead of opening
the files in the one already running
--present-mode <mode> How frames reach the display: fifo, autovsync or novsync
--frame-latency <n> Frames queued for the display: 1 to 3
--stroke-trace <stderr|file.csv>
Time each brush stroke's frames
--control <port> Start the localhost JSON-lines control server on this port
--control-token <64-hex> Control server token (default: a new random one, printed)
--control-token-file <path> Read the control server token from this file, or create it
with a new one
--automation-read-root <dir> Directory the control server may read files from
--automation-write-root <dir>
Directory the control server may write files to
-- Treat every later argument as a file, even one starting with `-`
A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored.
Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE,
PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE,
PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE,
PHOTOCRAFT_CONFIG_DIR.
";
/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when
/// stdout is closed or a pipe (`photocraft --help | head -1`).
fn print_stdout(text: &str) {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
}
/// Everything the desktop launcher takes from the command line, on top of the environment
/// defaults handed in to `parse_launch_args`.
#[derive(Debug, Default, PartialEq, Eq)]
struct LaunchArgs {
control_port: Option<u16>,
control_token: Option<String>,
control_token_file: Option<std::path::PathBuf>,
automation_read_root: Option<std::path::PathBuf>,
automation_write_root: Option<std::path::PathBuf>,
files: Vec<String>,
/// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
/// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
/// reported in a notice once the app is up; a control token is a usage error like a bad port.
unreadable_paths: Vec<String>,
safe_gpu: bool,
new_instance: bool,
/// Options that apply only to the process they're given to; a launch with any keeps its own.
launch_options: Vec<&'static str>,
present_mode_arg: Option<String>,
frame_latency_arg: Option<String>,
stroke_trace_arg: Option<String>,
in_window_menus: bool,
/// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
help: bool,
/// `--version`: print the version and exit 0; parsing stops there.
version: bool,
/// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
/// list fails the launch with exit status 2 (issues #701, #6429, #6431).
errors: Vec<String>,
}
/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
fn parse_launch_args(args: impl Iterator<Item = std::ffi::OsString>, mut out: LaunchArgs) -> LaunchArgs {
let mut args = args;
// After `--`, every argument is a file, even one that starts with `-`.
let mut only_files = false;
while let Some(a) = args.next() {
if only_files {
match a.into_string() {
Ok(s) => out.files.push(s),
Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
}
continue;
}
match a.to_str() {
Some("--") => only_files = true,
Some("--help" | "-h") => {
out.help = true;
break;
}
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => out.control_port = Some(port),
Err(error) => out.errors.push(error),
},
None => out.errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => out.control_token = Some(token),
Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => out.errors.push("--control-token: missing token value (expected `--control-token <token>`)".to_string()),
},
Some("--control-token-file") => match args.next() {
Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file <path>`)".to_string()),
},
Some("--automation-read-root") => {
out.launch_options.push("--automation-read-root");
match args.next() {
Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root <dir>`)".to_string()),
}
}
Some("--automation-write-root") => {
out.launch_options.push("--automation-write-root");
match args.next() {
Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root <dir>`)".to_string()),
}
}
Some("--safe-gpu") => {
out.launch_options.push("--safe-gpu");
out.safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
out.launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => out.present_mode_arg = Some(v),
"--frame-latency" => out.frame_latency_arg = Some(v),
_ => out.stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
out.launch_options.push("--in-window-menus");
out.in_window_menus = true;
}
Some("--new-instance") => out.new_instance = true,
Some("--version") => {
out.version = true;
break;
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
// and then fail to open the flag as a file (#6431).
Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
Some(s) => out.files.push(s.to_owned()),
None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
}
}
out
}
#[cfg(test)]
mod control_port_tests {
use super::{parse_control_port, usage_exit_code};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(usage_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(usage_exit_code(&errors), Some(2));
}
#[test]
fn control_port_accepts_valid_numbers() {
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, a following flag eaten as the value, and an empty value all fail,
// naming the offending value in the message.
let err = parse_control_port("78787", "--control").unwrap_err();
assert!(err.contains("78787"), "{err}");
assert!(parse_control_port("--safe-gpu", "--control").is_err());
assert!(parse_control_port("", "--control").is_err());
}
}
#[cfg(test)]
mod launch_args_tests {
use super::{LaunchArgs, parse_launch_args, usage_exit_code};
use std::ffi::OsString;
fn parse(values: &[&str]) -> LaunchArgs {
parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
}
#[test]
fn flags_without_a_value_are_usage_errors() {
// Issue #6429: a missing value must fail the launch, not silently fall back to a
// generated token or to no root.
for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
let parsed = parse(&[flag]);
assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
assert_eq!(usage_exit_code(&parsed.errors), Some(2));
}
}
#[test]
fn missing_value_does_not_clear_an_environment_default() {
// `--automation-read-root` with no value must not drop a root set through the env var.
let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
}
#[test]
fn values_are_parsed_and_no_errors_remain() {
let parsed = parse(&[
"--control",
"50494",
"--control-token",
"abcd",
"--control-token-file",
"/tmp/token",
"--automation-read-root",
"/r",
"--automation-write-root",
"/w",
"--safe-gpu",
"--in-window-menus",
"--new-instance",
"--present-mode",
"mailbox",
"photo.psd",
]);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
assert_eq!(parsed.control_port, Some(50494));
assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
assert!(parsed.safe_gpu);
assert!(parsed.in_window_menus);
assert!(parsed.new_instance);
assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
}
#[test]
fn version_stops_parsing() {
let parsed = parse(&["--version", "--control"]);
assert!(parsed.version);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let parsed = parse(&["-h", "--safegpu", "--control"]);
assert!(parsed.help);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
assert!(!parsed.help);
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
let mut launch = LaunchArgs {
automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
..Default::default()
};
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => launch.control_port = Some(port),
Err(error) => launch.errors.push(error),
}
}
launch = parse_launch_args(std::env::args_os().skip(1), launch);
if launch.help {
print_stdout(USAGE);
return Ok(());
}
if launch.version {
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
return Ok(());
}
// A malformed control port must not silently drop the control server (issue #701), a control
// or automation flag without its value must not silently fall back to a generated token or
// to no root (issue #6429), and an unknown option must not open the window (#6431): name the
// bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit
// status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = usage_exit_code(&launch.errors) {
for error in &launch.errors {
eprintln!("photocraft: {error}");
}
eprintln!("Run `photocraft --help` for usage.");
std::process::exit(code);
}
let LaunchArgs {
control_port,
control_token,
control_token_file,
automation_read_root,
automation_write_root,
files,
unreadable_paths,
// `--help` and `--version` print to standard output and exit 0. A usage error (an unknown
// option, a missing or bad value, a bad PHOTOCRAFT_CONTROL_PORT: issues #701, #6429, #6431)
// names the argument on standard error and exits 2, so a launcher sees the failure. Neither
// panics when the stream is closed or a pipe (`photocraft --help | head -1`).
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
let launch_args::FileNames { openable: files, unreadable: unreadable_paths } = launch.file_names();
let launch_args::LaunchArgs {
files: _,
safe_gpu,
new_instance,
mut launch_options,
present_mode_arg,
frame_latency_arg,
stroke_trace_arg,
in_window_menus,
help: _,
version: _,
errors: _,
new_instance,
present_mode,
frame_latency,
stroke_trace,
control: control_port,
auth,
automation,
mut launch_options,
} = launch;
// The log file lives under the settings directory; opened after the arguments, so `--version`
@@ -516,7 +182,7 @@ fn main() -> eframe::Result {
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
launch_options.push(launch_args::PerProcess::GpuAdapterEnv);
}
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
@@ -597,7 +263,7 @@ fn main() -> eframe::Result {
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
Err(e) => {
@@ -612,7 +278,7 @@ fn main() -> eframe::Result {
} else {
eprintln!("photocraft: using supplied control token");
}
let workspace = match photocraft_automation::AuthorizedWorkspace::new(automation_read_root.as_deref(), automation_write_root.as_deref()) {
let workspace = match automation.workspace() {
Ok(workspace) => workspace,
Err(error) => {
eprintln!("photocraft: cannot configure automation workspace: {error}");
@@ -701,7 +367,7 @@ fn main() -> eframe::Result {
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
}
// Testers' present mode and frame latency overrides (#1343), after the preference.
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
let present = presentation::Overrides::from_args_or_env(present_mode, frame_latency);
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
@@ -779,7 +445,7 @@ fn main() -> eframe::Result {
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.present = present_desc.clone();
if let Some(v) = &stroke_trace_arg {
if let Some(v) = &stroke_trace {
app.stroke_trace.set(v);
}
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
@@ -937,9 +603,10 @@ fn main() -> eframe::Result {
s.finish();
}
if let Ok(exe) = std::env::current_exe() {
// `--safe-gpu` first: after a `--`, it would be a file name.
let launched = std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.arg("--safe-gpu")
.args(std::env::args_os().skip(1))
.env_remove("WGPU_BACKEND")
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
.spawn();
+39 -28
View File
@@ -2,8 +2,10 @@
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
//! queue). Unset, the platform default applies. A variable's value that isn't one of these is ignored
//! with a warning in the log, never a failed start; the `--present-mode` and `--frame-latency` flags
//! take the same values, and a bad one is a usage error (#7071). A mode the driver may not offer is
//! never requested.
//! The result is logged at start and shown in the stroke trace.
use eframe::egui_wgpu::SurfaceConfig;
@@ -16,45 +18,54 @@ pub struct Overrides {
pub latency: Option<u32>,
}
/// A `--present-mode` / `PHOTOCRAFT_PRESENT_MODE` value, or `None` when it isn't one.
pub fn present_mode(v: &str) -> Option<PresentMode> {
match v.trim().to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => Some(PresentMode::Fifo),
"autovsync" => Some(PresentMode::AutoVsync),
"novsync" | "immediate" | "mailbox" | "autonovsync" => Some(PresentMode::AutoNoVsync),
_ => None,
}
}
/// A `--frame-latency` / `PHOTOCRAFT_FRAME_LATENCY` value (1 to 3), or `None` when it isn't one.
pub fn frame_latency(v: &str) -> Option<u32> {
v.trim().parse().ok().filter(|n| (1..=3).contains(n))
}
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
let mut warnings = Vec::new();
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let m = match v.to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => PresentMode::Fifo,
"autovsync" => PresentMode::AutoVsync,
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
_ => {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
return None;
}
};
Some(m)
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
Ok(n @ 1..=3) => Some(n),
_ => {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
None
let m = present_mode(v);
if m.is_none() {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
}
m
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let n = frame_latency(v);
if n.is_none() {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
}
n
});
(Overrides { mode, latency }, warnings)
}
impl Overrides {
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
/// `--present-mode` / `--frame-latency` when given (checked by the command line), else
/// `PHOTOCRAFT_PRESENT_MODE` / `PHOTOCRAFT_FRAME_LATENCY`, whose rejected values are logged.
pub fn from_args_or_env(mode: Option<PresentMode>, latency: Option<u32>) -> Self {
let env = |given: bool, name: &str| if given { None } else { std::env::var(name).ok() };
let (o, warnings) = parse(env(mode.is_some(), "PHOTOCRAFT_PRESENT_MODE").as_deref(), env(latency.is_some(), "PHOTOCRAFT_FRAME_LATENCY").as_deref());
for w in warnings {
log::warn!("{w}");
}
o
Self { mode: mode.or(o.mode), latency: latency.or(o.latency) }
}
/// `surface` with the overrides applied.
+9 -1
View File
@@ -1,6 +1,11 @@
//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
use photocraft_ui_egui::screen_picker::{Capture, Pending, Service};
// Only the platform capture backends (and the tests) build images and check sizes.
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
use photocraft_ui_egui::screen_picker::MAX_PIXELS;
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
use photocraft_ui_egui::screen_picker::ScreenImage;
pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Service> {
if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
@@ -34,6 +39,7 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Servic
}))
}
#[derive(Clone, Copy)]
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", target_os = "windows", test)), allow(dead_code))]
struct Placement {
index: usize,
x: i32,
@@ -41,9 +47,11 @@ struct Placement {
width: u32,
height: u32,
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option<usize> {
placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
+17 -3
View File
@@ -15,13 +15,13 @@ use std::sync::Arc;
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase",
"kys", "af", "afdesign", "afphoto", "afpub",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act",
"ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -71,6 +71,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Styles", &["asl"]),
("Swatches", &["aco", "act", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
@@ -560,6 +561,12 @@ fn shell_word(s: &str) -> String {
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
}
/// The release-check transport, or `None` inside Flatpak: the sandbox has no network (see the
/// manifest's finish-args) and `flatpak update` owns updates there.
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
flatpak_id.is_none().then(crate::updates::service)
}
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
@@ -624,6 +631,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
beep: Some(platform_beep),
check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()),
clipboard_set_image: Some({
let clip = clip.clone();
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
@@ -903,6 +911,12 @@ mod tests {
);
}
#[test]
fn flatpak_builds_have_no_update_transport() {
assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none());
assert!(update_transport(None).is_some());
}
#[test]
fn xwayland_command_matches_how_photocraft_was_installed() {
// Flatpak: WAYLAND_DISPLAY doesn't reach the sandbox's socket choice; flatpak's flags do.
+30 -47
View File
@@ -41,6 +41,8 @@ use photocraft_automation::security::{LineRead, generate_token, read_bounded_lin
use photocraft_ui_egui::{OsEvent, OsEventsFn};
use serde_json::{Value, json};
use crate::launch_args::PerProcess;
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// The Unix socket a sandboxed primary listens on, in the app's runtime directory.
@@ -131,20 +133,19 @@ pub struct LaunchContext<'a> {
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …), including the
/// ones this launch's environment sets (see [`env_launch_options`]).
pub launch_options: &'a [&'static str],
pub launch_options: &'a [PerProcess],
}
/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for
/// Variables documented as the equivalent of a per-launch flag, each with the option it stands for
/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for
/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this
/// process alone.
const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [
("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on),
("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on),
("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set),
("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set),
/// process alone. These are read where they take effect, not by the command line; the variables
/// that are a flag's fallback (`PHOTOCRAFT_IN_WINDOW_MENUS`, the automation roots) are counted by
/// [`crate::launch_args::parse`], as clap reads them.
const ENV_LAUNCH_OPTIONS: [(&str, PerProcess, ValueRule); 3] = [
("PHOTOCRAFT_PRESENT_MODE", PerProcess::PresentMode, present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", PerProcess::FrameLatency, frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", PerProcess::StrokeTrace, stroke_trace_is_on),
];
/// Whether a variable's value asks this launch for its own option. Each rule matches how that
@@ -169,29 +170,15 @@ fn stroke_trace_is_on(value: &str) -> bool {
!matches!(value.trim(), "" | "0" | "off" | "false")
}
/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is
/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does
/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too.
fn flag_is_on(value: &str) -> bool {
!value.is_empty() && value != "0"
}
/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty
/// root is read as no root unless `--control` is given, and that flag keeps its own process already.
fn path_is_set(value: &str) -> bool {
!value.is_empty()
}
/// The per-process options this launch's environment sets, named as the flags they stand for. A
/// launch that sets only variables would otherwise hand its files to the running instance, which
/// never sees the override, and exit.
pub fn env_launch_options() -> Vec<&'static str> {
/// The per-process options this launch's environment sets. A launch that sets only variables would
/// otherwise hand its files to the running instance, which never sees the override, and exit.
pub fn env_launch_options() -> Vec<PerProcess> {
env_launch_options_from(|name| std::env::var_os(name))
}
/// [`env_launch_options`] with the reader given, so the rule is testable without the environment.
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<&'static str> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect()
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<PerProcess> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, option, _)| *option).collect()
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
@@ -209,7 +196,8 @@ pub fn skip_reason(c: &LaunchContext) -> Option<String> {
return Some("some paths aren't valid Unicode".into());
}
if !c.launch_options.is_empty() {
return Some(format!("{} only applies to a new process", c.launch_options.join(", ")));
let options: Vec<String> = c.launch_options.iter().map(ToString::to_string).collect();
return Some(format!("{} only applies to a new process", options.join(", ")));
}
None
}
@@ -898,8 +886,8 @@ mod tests {
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None);
assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some());
assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some());
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
let reason = skip_reason(&LaunchContext { launch_options: &[PerProcess::SafeGpu, PerProcess::GpuAdapterEnv], ..base });
assert_eq!(reason.as_deref(), Some("--safe-gpu, PHOTOCRAFT_GPU_ADAPTER only applies to a new process"));
}
/// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime
@@ -983,30 +971,25 @@ mod tests {
#[test]
fn an_environment_override_counts_as_a_process_option() {
let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into()));
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]);
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec![PerProcess::PresentMode]);
assert_eq!(
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]),
vec!["--stroke-trace", "--automation-write-root"]
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_FRAME_LATENCY", "2")]),
vec![PerProcess::FrameLatency, PerProcess::StrokeTrace]
);
// Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way
// it is read where it takes effect, not by one rule shared across all six.
// it is read where it takes effect, not by one rule shared across all three.
assert!(with(&[]).is_empty());
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
assert!(with(&[("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
// The trace is off at `off` and `false` as well, and a value the presentation parser rejects
// is ignored there, so neither keeps a process of its own here.
assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty());
assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty());
// Spaces are part of the value only where the reader keeps them: the trace and the two
// presentation values are trimmed where they take effect, the menu flag and a path are not.
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]);
// The trace and the two presentation values are trimmed where they take effect.
assert_eq!(
with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]),
vec!["--in-window-menus", "--automation-write-root"]
with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]),
vec![PerProcess::PresentMode, PerProcess::StrokeTrace]
);
// A whitespace-only menu value is not empty, so it enables the menus there and here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]);
// `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]);
// The menus and the automation roots are flags' fallbacks: `launch_args::parse` counts them.
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "1"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]).is_empty());
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
+126
View File
@@ -0,0 +1,126 @@
//! Native HTTPS transport for release checks. Only public release metadata is requested.
use photocraft_engine::updates_cmds::{CheckFn, CheckResult, parse_release};
use std::time::Duration;
const LATEST: &str = "https://api.github.com/repos/storytold/photocraft/releases/latest";
const MAX_RESPONSE: u64 = 1024 * 1024;
pub fn service() -> CheckFn {
Box::new(|| {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("update check".into())
.spawn(move || {
let result = fetch(LATEST, Duration::from_secs(15));
if let Err(e) = &result {
log::warn!("Update check: {e}");
}
let _ = tx.send(result);
})
.map_err(|e| format!("Could not start update check: {e}"))?;
Ok(rx)
})
}
fn fetch(url: &str, timeout: Duration) -> CheckResult {
// Respect the OS trust store (including managed-network certificates), while still
// validating the certificate chain and server name.
let agent = ureq::Agent::config_builder()
.tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build())
.timeout_global(Some(timeout))
.max_redirects(0)
.build()
.new_agent();
let mut response = match agent
.get(url)
.header("User-Agent", "PhotoCraft-update-check")
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2026-03-10")
.call()
{
Ok(response) => response,
// The repository may not have published a stable release yet.
Err(ureq::Error::StatusCode(404)) => return Ok(None),
Err(ureq::Error::StatusCode(403 | 429)) => return Err("GitHub is limiting update checks. Please try again later.".into()),
Err(e) => return Err(format!("Could not contact the release server: {e}")),
};
if response.status() != 200 {
return Err(format!("Unexpected release server status: {}", response.status()));
}
let bytes = response.body_mut().with_config().limit(MAX_RESPONSE).read_to_vec().map_err(|e| format!("Could not read release response: {e}"))?;
parse_release(&bytes, photocraft_engine::build_info::VERSION)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
fn server(status: &str, body: &str, extra: &str) -> (String, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/latest", listener.local_addr().unwrap());
let response = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra}Connection: close\r\n\r\n{body}", body.len());
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|w| w == b"\r\n\r\n") {
let n = stream.read(&mut chunk).unwrap();
if n == 0 {
break;
}
request.extend_from_slice(&chunk[..n]);
}
let _ = stream.write_all(response.as_bytes());
String::from_utf8(request).unwrap()
});
(url, worker)
}
#[test]
fn fetches_release_metadata_with_generic_headers() {
let (url, worker) = server("200 OK", r#"{"tag_name":"v99.0.0","draft":false,"prerelease":false}"#, "");
let result = fetch(&url, Duration::from_secs(5)).unwrap().unwrap();
assert_eq!(result.version, "99.0.0");
let request = worker.join().unwrap().to_ascii_lowercase();
assert!(request.contains("user-agent: photocraft-update-check\r\n"));
assert!(request.contains("accept: application/vnd.github+json"));
assert!(!request.contains("authorization:") && !request.contains("cookie:"));
}
#[test]
fn handles_missing_release_rate_limits_and_bad_responses() {
for (status, body, extra, expected_none) in [
("404 Not Found", "", "", true),
("403 Forbidden", "", "", false),
("429 Too Many Requests", "", "", false),
("500 Internal Server Error", "", "", false),
("200 OK", "invalid JSON", "", false),
("302 Found", "", "Location: https://untrusted.example/\r\n", false),
] {
let (url, worker) = server(status, body, extra);
let result = fetch(&url, Duration::from_secs(5));
if expected_none {
assert_eq!(result, Ok(None));
} else {
assert!(result.is_err(), "{status}");
}
worker.join().unwrap();
}
}
#[test]
fn caps_response_size_and_times_out() {
let (url, worker) = server("200 OK", &" ".repeat(MAX_RESPONSE as usize + 1), "");
assert!(fetch(&url, Duration::from_secs(5)).is_err());
worker.join().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/latest", listener.local_addr().unwrap());
let start = std::time::Instant::now();
assert!(fetch(&url, Duration::from_millis(100)).is_err());
assert!(start.elapsed() < Duration::from_secs(2));
}
}
@@ -1,95 +0,0 @@
//! `photocraft --help` and unknown options must not open the window (#6431): before the fix,
//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as
//! `--safegpu` launched the GUI and logged "Couldn't open --help".
//!
//! These run the real binary, and every case must exit before the window opens: Windows and
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the
//! other usage errors, so stderr shows whether those arguments were taken as unknown options.
/// Run `photocraft` with `args` in an isolated config dir; return the exit
/// status, stdout and stderr.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned())
}
#[test]
fn help_prints_usage_and_exits_0() {
for flag in ["--help", "-h"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}");
for option in ["--safe-gpu", "--new-instance", "--control <port>", "--version", "--"] {
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
}
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
}
}
#[test]
fn help_wins_over_later_arguments() {
let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}");
}
#[test]
fn unknown_options_exit_2_naming_them() {
for flag in ["--safegpu", "-x", "--help=yes"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}");
assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}");
assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}");
assert!(stdout.is_empty(), "stdout: {stdout}");
}
}
#[test]
fn unknown_option_errors_are_reported_with_control_port_errors() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}");
}
#[test]
fn arguments_after_double_dash_are_files() {
// `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage
// nor is an unknown option; only the bad port is reported.
let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stdout.contains("Usage:"), "stdout: {stdout}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
#[test]
fn macos_process_serial_number_is_still_ignored() {
// Old macOS passes `-psn_<n>` when launching from Finder; it is neither a file nor an error.
let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
#[test]
fn new_instance_is_a_known_option() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
@@ -0,0 +1,157 @@
//! `photocraft --help` and unknown options must not open the window (#6431): before the fix,
//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as
//! `--safegpu` launched the GUI and logged "Couldn't open --help".
//!
//! These run the real binary, and every case must exit before the window opens: Windows and
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
//! Cases whose arguments are valid add a usage error before them: `--control-token` with
//! `--control-token-file` is only checked once every argument is read (and only with `--control`),
//! so stderr shows how the other arguments were taken.
/// The variables the command line reads: a test that wants one sets it.
const VARS: [&str; 6] = [
"PHOTOCRAFT_CONTROL_PORT",
"PHOTOCRAFT_CONTROL_TOKEN",
"PHOTOCRAFT_CONTROL_TOKEN_FILE",
"PHOTOCRAFT_AUTOMATION_READ_ROOT",
"PHOTOCRAFT_AUTOMATION_WRITE_ROOT",
"PHOTOCRAFT_IN_WINDOW_MENUS",
];
/// A usage error found only after every argument is read.
const LATE_ERROR: [&str; 6] = ["--control", "0", "--control-token", "abcd", "--control-token-file", "token"];
/// How a run of `photocraft` ended.
struct Run {
status: std::process::ExitStatus,
stdout: String,
stderr: String,
}
/// Run `photocraft` with `args` and `vars` in an isolated config dir.
fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"));
for var in VARS {
cmd.env_remove(var);
}
let output = cmd
.args(args)
.envs(vars.iter().copied())
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
Run { status: output.status, stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
fn run_photocraft(args: &[&str]) -> Run {
run_with(args, &[])
}
/// [`LATE_ERROR`] then `args`: exit 2, and the stderr.
fn late_error(args: &[&str], vars: &[(&str, &str)]) -> String {
let Run { status, stdout, stderr } = run_with(&[&LATE_ERROR[..], args].concat(), vars);
assert_eq!(status.code(), Some(2), "{args:?}: {stderr}");
assert!(stderr.contains("cannot be used with"), "{args:?}: {stderr}");
assert!(stdout.is_empty(), "{args:?}: {stdout}");
stderr
}
#[test]
fn help_prints_usage_and_exits_0() {
for flag in ["--help", "-h"] {
let Run { status, stdout, stderr } = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
assert!(stdout.contains("Usage: photocraft"), "{flag} stdout: {stdout}");
for option in ["--safe-gpu", "--new-instance", "--control <PORT>", "--version", "`--`", "PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_NEW_INSTANCE"] {
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
}
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
}
}
#[test]
fn help_wins_over_later_arguments() {
let Run { status, stdout, .. } = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert!(stdout.contains("Usage: photocraft"), "stdout: {stdout}");
}
#[test]
fn version_prints_the_long_version() {
let Run { status, stdout, .. } = run_photocraft(&["--version", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert_eq!(stdout, format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
}
#[test]
fn unknown_options_exit_2_naming_them() {
for (args, named) in [
(&["--safegpu"][..], "unexpected argument '--safegpu'"),
(&["-x"][..], "unexpected argument '-x'"),
(&["--help=yes"][..], "unexpected value 'yes' for '--help'"),
// An unknown flag after a bad value is reported first: clap drops the pending value's error.
(&["--control", "nope", "--safegpu"][..], "unexpected argument '--safegpu'"),
(&["--control", "nope"][..], "invalid value 'nope' for '--control <PORT>'"),
] {
let Run { status, stdout, stderr } = run_photocraft(args);
assert_eq!(status.code(), Some(2), "{args:?} must be a usage error, stderr: {stderr}");
assert!(stderr.contains(named), "stderr: {stderr}");
assert!(stderr.contains("--help"), "stderr should point at --help: {stderr}");
assert!(stdout.is_empty(), "stdout: {stdout}");
}
}
#[test]
fn arguments_after_double_dash_are_files() {
// `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage
// nor is an unknown option; only the late error is reported.
let stderr = late_error(&["--", "--help", "-x"], &[]);
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
}
#[test]
fn macos_process_serial_number_is_still_ignored() {
// Old macOS passes `-psn_<n>` when launching from Finder; it is neither a file nor an error.
let stderr = late_error(&["-psn_0_12345"], &[]);
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
}
#[test]
fn new_instance_is_a_known_option() {
let stderr = late_error(&["--new-instance"], &[]);
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
}
/// The variables stand in for their flags: a flag wins, an empty variable counts as unset, a bad
/// value is a usage error naming the variable, and a flag without its value is still an error
/// when its variable is set (#6429).
#[test]
fn variables_are_the_flags_fallbacks() {
let Run { status, stderr, .. } = run_with(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("invalid value 'nope' for 'PHOTOCRAFT_CONTROL_PORT'"), "stderr: {stderr}");
// With the flag (in LATE_ERROR), the variable is never read.
let stderr = late_error(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]);
assert!(!stderr.contains("PHOTOCRAFT_CONTROL_PORT"), "stderr: {stderr}");
// The variable turns the control server on as the flag does, so the token pair is checked.
let Run { status, stderr, .. } = run_with(&LATE_ERROR[2..], &[("PHOTOCRAFT_CONTROL_PORT", "0")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
let Run { status, stderr, .. } = run_with(&["--automation-read-root"], &[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "/env/read")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("a value is required for '--automation-read-root <DIR>'"), "stderr: {stderr}");
// The other half of the token pair from a variable is the same either/or error, naming it
// but never showing the token.
let token = "a".repeat(64);
let Run { status, stderr, .. } = run_with(&["--control", "0", "--control-token-file", "token"], &[("PHOTOCRAFT_CONTROL_TOKEN", token.as_str())]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("PHOTOCRAFT_CONTROL_TOKEN") && stderr.contains("cannot be used with") && !stderr.contains(&token), "stderr: {stderr}");
}
@@ -9,10 +9,21 @@
/// workspace check instead of failing on token validation first.
const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// How a run of `photocraft` ended.
struct Run {
status: std::process::ExitStatus,
stderr: String,
}
/// Run `photocraft` with the given arguments in an isolated config dir and
/// return the exit status and stderr. Each call gets its own config dir (tests run in
/// return how it ended. Each call gets its own config dir (tests run in
/// parallel), and no display variables, so a regression fails fast instead of opening a window.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
fn run_photocraft(args: &[&str]) -> Run {
run_with(args, &[])
}
/// [`run_photocraft`], with `vars` set.
fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
@@ -29,37 +40,40 @@ fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.envs(vars.iter().copied())
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stderr).into_owned())
Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
#[test]
fn invalid_control_token_exits_with_status_2() {
// `validate_token` rejects a token that is not exactly 64 hex characters.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
}
#[test]
fn both_token_and_token_file_exits_with_status_2() {
// `server_token` rejects the combination of a supplied token and a token file.
// The command line rejects a supplied token together with a token file (#7071), before
// `server_token` would.
let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let token_file = dir.join("token.txt");
std::fs::write(&token_file, VALID_TOKEN).unwrap();
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
}
#[test]
fn missing_automation_read_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the read root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
let Run { status, stderr } =
run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
@@ -67,7 +81,17 @@ fn missing_automation_read_root_exits_with_status_2() {
#[test]
fn missing_automation_write_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the write root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
let Run { status, stderr } =
run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
#[test]
fn automation_roots_come_from_the_environment_too() {
for var in ["PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_AUTOMATION_WRITE_ROOT"] {
let Run { status, stderr } = run_with(&["--control", "0", "--control-token", VALID_TOKEN], &[(var, "/nonexistent/photocraft-test-path")]);
assert_eq!(status.code(), Some(2), "{var}: missing root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "{var}: stderr: {stderr}");
}
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, linked into one test binary.
mod command_line_usage;
mod control_exit_code;
mod startup_without_display;
+20 -1
View File
@@ -63,4 +63,23 @@ layers because that would lose their group's blending semantics.
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
CLI parsing checks the flags and their values before any work starts. A flag the subcommand doesn't take (a typo such as `--fromat`), a flag given twice (only `--cmd` and `--params` may repeat), a value out of range (`--quality 0`), malformed `--new` or `--params` JSON, `<FILE>` together with `--new`, and `--control-token` together with `--control-token-file` are all usage errors (exit status 2), rather than being ignored or failing later. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. A path that is not valid Unicode reaches the command instead of being a usage error: `convert` reads and writes it, while `info`, `run` and `batch` open files through the automation layer, which still needs UTF-8 paths and fails with exit status 1 (`droplet` takes its arguments as text, so it refuses such a path as a usage error). `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand.
`mcp` and `serve` reject positional arguments, empty values and options for the wrong mode before
starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port` (or
`PHOTOCRAFT_CONTROL_PORT`, below). Configure automation roots on the desktop process when using
bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md) for examples.
`mcp` and `serve` fall back to environment variables: `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` for the token flags, and `PHOTOCRAFT_CONTROL_PORT` for `serve --port` and `mcp --bridge` (which then bridges to `127.0.0.1:<port>`). The automation roots are flags only: the desktop app's `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` grant the CLI nothing. A flag wins over its variable, an empty variable counts as unset, and a bad value is a usage error naming the variable. When `PHOTOCRAFT_CONTROL_PORT` switches `serve` to TCP or `mcp` to bridging, the command says so on standard error. A token together with a token file is a usage error only where a token is read (`serve` on TCP, `mcp` bridging), so a stale exported pair doesn't stop stdio `serve` or headless `mcp`. An automation root given to `mcp` while the variable would switch it to bridging is a usage error too: the roots are the headless engine's, and the bridge would drop them. `<subcommand> --help` lists each variable (unless it is set but empty), never with the token's value. A variable holds a port, so `PHOTOCRAFT_CONTROL_PORT=127.0.0.1:7878` is refused; only `--bridge` takes `host:port`.
This checking is not a security policy layer. Engine commands remain responsible for validating their own parameters.
### Shell completions and man pages
The Linux `.deb` and `.rpm` packages install `man photocraft-cli` (plus a page per subcommand, such as `man photocraft-cli-convert`) and completions for bash, zsh and fish (zsh's in `/usr/share/zsh/site-functions`, which the `.deb` also links from Debian's `/usr/share/zsh/vendor-completions`); the Linux tarball and the FreeBSD tarball carry the same files, the FreeBSD one under `man/man1` and `share/`. The pages list each flag's environment variable, as `--help` does. Without a package, generate them from a source checkout:
```sh
cargo run -p photocraft-cli --example man-and-completions -- out/
```
`out/` then holds `photocraft-cli.bash`, `_photocraft-cli` (zsh), `photocraft-cli.fish`, `photocraft-cli.elv` (elvish), `_photocraft-cli.ps1` (PowerShell) and the `.1` man pages. For example, copy `photocraft-cli.bash` to `~/.local/share/bash-completion/completions/photocraft-cli`, `photocraft-cli.fish` into `~/.config/fish/completions/`, `_photocraft-cli` into a directory on zsh's `$fpath`, and read a page with `man -l out/photocraft-cli.1`. The desktop app `photocraft` has neither.
+10
View File
@@ -20,6 +20,16 @@ Bridge to a running desktop application with the same private token file used by
photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file /private/path/control.token
```
Use absolute root paths in MCP client configurations, since a relative root is resolved from the
client's launch directory. In bridge mode the roots are configured on the desktop app, and root
flags on `photocraft-cli mcp --bridge` are rejected. Explicit `--control-token` and
`--control-token-file` flags require `--bridge` (or `PHOTOCRAFT_CONTROL_PORT`) and are mutually
exclusive; bridge credentials can also come from `PHOTOCRAFT_CONTROL_TOKEN` or
`PHOTOCRAFT_CONTROL_TOKEN_FILE`. Each launch option
except `--help`/`-h` takes one non-empty value and cannot be repeated. Stray positional arguments,
missing values and options for the wrong mode are usage errors (exit 2) reported on stderr before
the server starts.
Tools cover session/document operations, command discovery and execution, batching, and—when bridged—UI inspection and control. Tool schemas improve correctness but are not authorization boundaries.
## From an installed release
+13
View File
@@ -79,6 +79,9 @@ sync_github_name = true
[people.maxm11]
sync_github_name = true
[people.mweinbach]
sync_github_name = true
[people.skraus2]
sync_github_name = true
@@ -251,3 +254,13 @@ sync_github_name = true
[people.benandez]
real_name = "Martin Ball"
[people.ronibalthazar]
sync_github_name = true
[people.batuhangozegu]
real_name = "Batuhan Gözegü"
sync_github_name = false
[people.drunkeninja42]
real_name = "Kushagra Sharma"
+3 -3
View File
@@ -108,11 +108,11 @@ The same reader is shared, as an independent copy, with VectorCraft's `vectorcra
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
mutations, here and in `photocraft-io`'s `tests/it/affinity.rs`.
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four
Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/it/real_files.rs`
parses them; `photocraft-io`'s `tests/it/affinity_corpus.rs` imports and flattens each one and
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
ceiling per file).
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
+26 -6
View File
@@ -15,6 +15,11 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
/// A save appends one table; real files have one to a few hundred.
const MAX_TABLES: usize = 4096;
/// Distinct ids the allocation tables may name; each costs a map slot even when only deleted.
const MAX_ENTRIES: usize = 1 << 22;
/// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry
/// and as its map key), and tables can point many names at the same bytes.
const MAX_NAME_BYTES: usize = 32 << 20;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
@@ -164,8 +169,9 @@ impl<'a> Archive<'a> {
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<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 name_bytes = 0usize;
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
@@ -222,19 +228,31 @@ impl<'a> Archive<'a> {
} else {
stored
};
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
record = Some(Box::new(Entry { id, name: String::new(), offset, size, compressed, crc, compression }));
}
2 => {}
_ => return Err(Error::Malformed("unknown allocation record")),
}
if flag == 0 {
let len = usize::from(c.u16()?);
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
names.entry(id).or_insert(name);
let raw = c.take(len)?;
// Only the first name per id is kept: charge and copy just that one.
if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) {
let name = String::from_utf8_lossy(raw).into_owned();
name_bytes = name_bytes.saturating_add(name.len());
if name_bytes > MAX_NAME_BYTES {
return Err(Error::Limit("archive entry names too large"));
}
slot.insert(name);
}
}
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
if replace {
// Deletion records are five bytes each: cap what distinct ids may retain.
if latest.len() >= MAX_ENTRIES && !latest.contains_key(&id) {
return Err(Error::Limit("too many archive entries"));
}
latest.insert(id, (date, tables, record));
}
}
@@ -248,7 +266,7 @@ impl<'a> Archive<'a> {
for (id, (_, _, record)) in latest {
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
e.name = name.clone();
entries.insert(e.name.clone(), e);
entries.insert(e.name.clone(), *e);
}
}
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
@@ -278,6 +296,9 @@ impl<'a> Archive<'a> {
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
// Charge before decoding: a corrupt entry that fails its checksum has already cost the
// decompression, and an importer that recovers must not get it again for free.
self.extracted = total;
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
if c.take(4)? != b"#Fil" {
return Err(Error::Malformed("archive entry has no data block"));
@@ -297,7 +318,6 @@ impl<'a> Archive<'a> {
if crc32fast::hash(&out) != e.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
self.extracted = total;
Ok(out)
}
}
+42 -2
View File
@@ -9,17 +9,21 @@ const MAX_NODES: usize = 4_000_000;
const MAX_COMPOUND_DEPTH: usize = 64;
const MAX_OUTLINE_VISITS: usize = 500_000;
const MAX_OUTLINE_POINTS: usize = 4_000_000;
/// Curve records decoded across the document. A shared curve is decoded again for each
/// occurrence, and records that only move handles add no points.
const MAX_OUTLINE_RECORDS: usize = 8_000_000;
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
/// transform. Bound work across the entire document instead of memoizing by object id alone.
pub(crate) struct Budget {
visits_left: usize,
points_left: usize,
records_left: usize,
}
impl Default for Budget {
fn default() -> Self {
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS, records_left: MAX_OUTLINE_RECORDS }
}
}
@@ -30,6 +34,12 @@ impl Budget {
Ok(())
}
/// Charge curve records before they are decoded.
fn records(&mut self, n: usize) -> Result<(), &'static str> {
self.records_left = self.records_left.checked_sub(n).ok_or("curve geometry budget exhausted")?;
Ok(())
}
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
@@ -138,7 +148,21 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
let corners = match s.obj(crvs, b"CnrD") {
Some(c) => {
let fields: Vec<&Value> = s.positional(c).collect();
// The corner arrays are scanned for every occurrence of a shared curve, so charge
// them like records before they are walked.
if let Some(Value::Array(idx)) = fields.get(1)
&& let Err(reason) = r.geometry_budget.records(idx.len())
{
r.warn(reason);
return None;
}
corner_radii(fields)
}
None => None,
};
let mut path = Path::default();
let mut seen = 0usize;
for index in 0..count {
@@ -152,6 +176,10 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<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 +286,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"));
}
}
+75 -18
View File
@@ -288,6 +288,12 @@ const MAX_NODES: usize = 500_000;
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
const MAX_CHILD_SLOTS: usize = 8_000_000;
/// Bytes of layer names the model may retain across all occurrences.
const MAX_NAME_BYTES: usize = 64 << 20;
/// Page rectangles the model may retain across all spreads (real books have a few hundred).
const MAX_PAGES: usize = 1 << 18;
/// Bytes of font names the text runs may retain across the document.
const MAX_FONT_NAME_BYTES: usize = 16 << 20;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
@@ -304,6 +310,9 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
child_slots: usize,
name_bytes: usize,
pages: usize,
font_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 +329,9 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
warnings: BTreeMap::new(),
nodes: 0,
child_slots: 0,
name_bytes: 0,
pages: 0,
font_name_bytes: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
@@ -361,7 +373,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn("an unknown kind of page");
continue;
}
let pages = pages(s, spread);
let pages = self.pages(spread)?;
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
@@ -394,10 +406,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
// Charge every raw child slot (null slots included) against a document-wide budget
// before filtering. A shared group with a large null-filled Chld array could otherwise
// be rescanned through each reference without the node limit noticing.
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
self.charge_slots(s.array_len(parent, b"Chld").unwrap_or(0))?;
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
@@ -419,6 +428,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Ok((out, mask))
}
/// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`].
pub(crate) fn charge_slots(&mut self, n: usize) -> Result<(), Error> {
self.child_slots = self.child_slots.saturating_add(n);
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
Ok(())
}
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
let s = self.s;
if depth > MAX_TREE_DEPTH {
@@ -437,7 +455,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
// A shared layer's name is copied for every occurrence, so charge the bytes first.
let name = s.str(id, b"Desc").unwrap_or_default();
self.name_bytes = self.name_bytes.saturating_add(name.len());
if self.name_bytes > MAX_NAME_BYTES {
return Err(Error::Limit("layer names too large"));
}
let name = name.to_string();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
@@ -460,6 +484,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let (mask, attached_mask) = self.attached_masks(id, world)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
self.charge_slots(s.array_len(id, b"FiEf").unwrap_or(0))?;
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
self.warn("layer effects (shadows, glows, outlines, bevels…)");
}
@@ -536,6 +561,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
// Every occurrence of a shared layer scans its whole attachment array, null slots included.
self.charge_slots(s.array_len(id, b"AdCh").unwrap_or(0))?;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
let mut pixels: Option<Image> = None;
@@ -584,21 +611,51 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> = s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
if !v.is_empty() {
return v;
impl Reader<'_, '_, '_> {
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads). A spread listed several times, or sharing one
/// large page array, is expanded into a new vector each time, so the slots scanned and the
/// rectangles retained are charged across the document.
fn pages(&mut self, spread: ObjId) -> Result<Vec<Rect>, Error> {
let s = self.s;
if let Some(md) = s.obj(spread, b"SpMd") {
self.charge_slots(s.array_len(md, b"PagR").unwrap_or(0))?;
let remaining = MAX_PAGES.saturating_sub(self.pages);
let v: Vec<Rect> = s
.objs(md, b"PagR")
.into_iter()
.filter_map(|p| s.floats::<4>(p, b"rctp").map(rect))
.filter(|r| r.x1 > r.x0 && r.y1 > r.y0)
.take(remaining.saturating_add(1))
.collect();
if !v.is_empty() {
self.charge_pages(v.len())?;
return Ok(v);
}
}
Ok(match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
self.charge_pages(n as usize)?;
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
}
_ => Vec::new(),
})
}
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
/// Counts retained font-name bytes against [`MAX_FONT_NAME_BYTES`]; false once it is spent.
pub(crate) fn charge_font_name(&mut self, n: usize) -> bool {
self.font_name_bytes = self.font_name_bytes.saturating_add(n);
self.font_name_bytes <= MAX_FONT_NAME_BYTES
}
/// Counts retained page rectangles against [`MAX_PAGES`].
fn charge_pages(&mut self, n: usize) -> Result<(), Error> {
self.pages = self.pages.saturating_add(n);
if self.pages > MAX_PAGES {
return Err(Error::Limit("too many pages"));
}
_ => Vec::new(),
Ok(())
}
}
+7 -1
View File
@@ -232,9 +232,15 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
}
};
let grad = s.obj(fill, b"Grad")?;
// A shared gradient is decoded again for every occurrence: charge the arrays scanned
// and copy only as many positions as there are stops to keep.
if r.charge_slots(s.array_len(grad, b"Cols").unwrap_or(0).saturating_add(s.array_len(grad, b"Posn").unwrap_or(0))).is_err() {
r.warn("gradients beyond the document work budget (left empty)");
return Some(Paint::None);
}
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
_ => Vec::new(),
};
let mut stops = Vec::new();
+15 -7
View File
@@ -181,24 +181,32 @@ fn plane(
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<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 });
// The scan below runs for every occurrence of a shared bitmap, whatever it produces.
r.charge_slots(states.len().min(tw.saturating_mul(th)))?;
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let state = &if let Value::UInt(u) = state { u8::try_from(*u).unwrap_or(255) } else { 255 };
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
let fill = match state {
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
next += 1;
let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) };
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
+2 -1
View File
@@ -332,7 +332,8 @@ impl Parser<'_> {
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
// A zero width passes the length check for any count, then costs a Value per record.
if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
+26 -1
View File
@@ -1,4 +1,4 @@
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
@@ -258,6 +258,20 @@ mod archive {
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_failed_read_still_spends_the_extraction_allowance() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
let mut broken = file.clone();
broken[90] ^= 0x55;
// Room for exactly one read: the failed attempt must use it up, so a caller that
// recovers from the checksum error cannot make the same entry decode again.
let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len(), ..Limits::default() }).unwrap();
assert!(a.read("doc.dat").is_err());
assert_eq!(a.extracted(), d.len());
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
@@ -302,6 +316,17 @@ mod archive {
}
}
#[test]
fn fixed_records_of_zero_width_are_rejected_before_any_value_is_made() {
// Four million records of no bytes each pass a length check that multiplies by the width.
let mut f = vec![0x80 | 0x2c];
f.extend(tag(b"Big_").0.to_le_bytes());
f.extend(4_000_000u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
let d = synth::stream(&[(tag(b"Big_"), F::Raw(f))]);
assert_eq!(stream::parse(&d).unwrap_err(), Error::Malformed("array longer than its data"));
}
#[test]
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
+30 -5
View File
@@ -26,11 +26,17 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let mut v = Vec::new();
// One past the allowance is enough to trip the check below; shared segments
// repeated by reference must not expand beyond it first.
let room = MAX_CHARS.saturating_sub(total).saturating_add(1);
for seg in s.objs(glyphs, b"Mixd") {
if v.len() >= room {
break;
}
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(room - v.len())));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(room.saturating_sub(v.len())));
}
v
}
@@ -100,6 +106,24 @@ fn int(v: &Value) -> Option<i64> {
}
}
/// Longest font name kept; real PostScript and family names are far shorter.
const MAX_FONT_NAME: usize = 256;
/// A font name for one run. Runs repeat the same shared string, so its copies are charged
/// against a document-wide byte budget; an overlong name or an exhausted budget leaves it empty.
fn font_name(r: &mut Reader, font: Option<ObjId>, tag: &[u8; 4]) -> String {
let name = font.and_then(|f| r.s.str(f, tag)).unwrap_or_default();
if name.len() > MAX_FONT_NAME {
r.warn("font names longer than 256 bytes (left empty)");
return String::new();
}
if !r.charge_font_name(name.len()) {
r.warn("font names beyond the document metadata budget (left empty)");
return String::new();
}
name.to_string()
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
@@ -119,11 +143,12 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
r.warn("horizontally scaled text");
}
let font = s.obj(attrs, b"DFnt");
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
let postscript = font_name(r, font, b"Post");
let family = font_name(r, font, b"Famy");
TextRun {
text,
postscript: get(b"Post"),
family: get(b"Famy"),
postscript,
family,
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
size,
@@ -48,8 +48,10 @@ fn isolated(name: &str, check: impl FnOnce()) {
check();
return;
}
// The harness names a test by its module path below the crate root.
let test = module_path!().split_once("::").map_or(name.to_string(), |(_, m)| format!("{m}::{name}"));
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", name, "--nocapture"])
.args(["--exact", &test, "--nocapture"])
.env("PHOTOCRAFT_COMPOUND_CASE", name)
.spawn()
.unwrap();
+4
View File
@@ -0,0 +1,4 @@
//! The crate's integration tests, linked into one test binary.
mod compound_limits;
mod real_files;
+1
View File
@@ -21,4 +21,5 @@ rustfft = "6.4.1"
rayon = { workspace = true }
[dev-dependencies]
photocraft-testkit = { workspace = true }
serde_json = { workspace = true }
+57 -7
View File
@@ -1,13 +1,33 @@
//! Timing for the heavier second-batch filters on a ~24 MP (6000×4000) RGBA8 layer.
//! `cargo run --release -p photocraft-algo --example bench_filters2 [filter-name-substring]`
//!
//! `cargo run --release -p photocraft-algo --example bench_filters2 [name-substring] [--reps N] [--quick] [--json <file>]`
//!
//! `--json` writes the report `cargo xtask perf` reads: `{"rows": [{"name", "samples_ms"}]}`.
//! `--quick` runs the list on a 1500×1000 layer instead, for `cargo xtask perf --quick`.
use photocraft_algo::*;
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn main() {
let only = std::env::args().nth(1);
let (w, h) = (6000, 4000);
let a: Vec<String> = std::env::args().skip(1).collect();
let flag = |k: &str| a.iter().position(|v| v == k);
let value = |k: &str| flag(k).and_then(|i| a.get(i + 1)).cloned();
let mut only: Option<String> = None;
let mut i = 0;
while i < a.len() {
match a[i].as_str() {
"--json" | "--reps" => i += 2,
"--quick" => i += 1,
_ => {
only.get_or_insert_with(|| a[i].clone());
i += 1;
}
}
}
let reps: usize = value("--reps").and_then(|v| v.parse().ok()).unwrap_or(1).max(1);
let quick = flag("--quick").is_some();
let (w, h) = if quick { (1500, 1000) } else { (6000, 4000) };
let r = Rect::new(0, 0, w, h);
let mut s = Surface::new(PixelFormat::RGBA8);
// Busy deterministic content (gradients plus texture) so edge-aware filters do real work.
@@ -55,19 +75,49 @@ fn main() {
},
FilterParams::Relight { angle: 45.0, elevation: 40.0, intensity: 40.0, ambient: 55.0, warmth: 0.0, softness: 25.0 },
];
let mut rows: Vec<(String, Vec<f64>)> = Vec::new();
for p in cases {
if only.as_deref().is_some_and(|o| !p.label().to_lowercase().contains(&o.to_lowercase())) {
continue;
}
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
// Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle.
let name = match p {
FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()),
_ => p.label().to_string(),
};
println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed());
drop(out);
let mut samples = Vec::with_capacity(reps);
for _ in 0..reps {
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
let ms = t.elapsed().as_secs_f64() * 1e3;
samples.push(ms);
drop(out);
}
let mut sorted = samples.clone();
sorted.sort_by(f64::total_cmp);
let median = match sorted.len() % 2 {
1 => sorted[sorted.len() / 2],
_ => (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0,
};
println!("{name:<18} {w}x{h} RGBA8: {median:.1} ms{reps_note}", reps_note = if reps > 1 { format!(" (median of {reps})") } else { String::new() });
rows.push((name, samples));
}
if let Some(path) = value("--json") {
// Two fields per row and ASCII filter labels: hand-written rather than a serde dependency
// this crate does not otherwise need.
let body: Vec<String> = rows
.iter()
.map(|(name, samples)| {
let s: Vec<String> = samples.iter().map(|v| format!("{v:.4}")).collect();
format!("{{\"name\":\"{name}\",\"samples_ms\":[{}]}}", s.join(","))
})
.collect();
let text = format!("{{\"rows\":[{}]}}", body.join(","));
if let Err(e) = std::fs::write(&path, text) {
eprintln!("write {path}: {e}");
std::process::exit(1);
}
println!("report: {path}");
}
}
+6 -1
View File
@@ -1,5 +1,6 @@
//! Full filter timing, including tile reads, filtering, writeback and pruning.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- facet 6000 4000 u8 3 output.bin
//! Also: crystallize, crystallize-large, pointillize, pointillize-large.
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
use photocraft_algo::{FilterParams, apply_in};
@@ -29,11 +30,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
_ => return Err("depth must be u8, u16 or f32".into()),
};
let params = match case {
"crystallize" => FilterParams::Crystallize { cell_size: 10.0, seed: 37 },
"crystallize-large" => FilterParams::Crystallize { cell_size: 100.0, seed: 37 },
"pointillize" => FilterParams::Pointillize { cell_size: 5.0, seed: 37, background: [1.0; 4] },
"pointillize-large" => FilterParams::Pointillize { cell_size: 100.0, seed: 37, background: [1.0; 4] },
"facet" => FilterParams::Facet,
"halftone" => FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] },
"halftone-small" => FilterParams::ColorHalftone { max_radius: 2.0, angles: [108.0, 162.0, 90.0, 45.0] },
"halftone-large" => FilterParams::ColorHalftone { max_radius: 64.0, angles: [108.0, 162.0, 90.0, 45.0] },
_ => return Err("case must be facet, halftone, halftone-small or halftone-large".into()),
_ => return Err("unknown pixelate benchmark case".into()),
};
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
"rgb" => ColorMode::Rgb,
+32
View File
@@ -0,0 +1,32 @@
//! Timing and output hash for Content-Aware Scale's seam carving core (P28).
//!
//! `cargo run --release -p photocraft-algo --example bench_seam [width height new_width channels]`
//!
//! Prints the carve time and an FNV-1a hash of the output's raw f32 bits, so two builds can be
//! compared with `cmp`-level strictness without writing tens of megabytes of pixels to disk.
use std::time::Instant;
fn main() {
let a: Vec<String> = std::env::args().skip(1).collect();
let num = |i: usize, d: usize| a.get(i).and_then(|s| s.parse().ok()).unwrap_or(d);
let (w, h, new_w, ch) = (num(0, 6000), num(1, 4000), num(2, 5400), num(3, 4));
if !(1..=4).contains(&ch) {
eprintln!("channels must be 1..=4");
std::process::exit(2);
}
// Deterministic photo-ish content: gradients with fine detail, so the energy map carries
// structure instead of one flat value per row.
let img: Vec<f32> = (0..w * h)
.flat_map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let d = ((x * 0.13).sin() * (y * 0.07).cos()).abs() * 0.3;
[(x / w as f32) * 0.6 + d, (y / h as f32) * 0.9 - d, d, 1.0].into_iter().take(ch)
})
.collect();
let t = Instant::now();
let out = photocraft_algo::seam::carve_width(w, h, ch, &img, None, new_w);
let ms = t.elapsed().as_secs_f64() * 1e3;
let hash =
out.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, v| v.to_bits().to_le_bytes().iter().fold(h, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)));
println!("{w}x{h}x{ch} -> {new_w}: {ms:.1} ms, {} samples, fnv {hash:016x}", out.len());
}
+60 -17
View File
@@ -114,6 +114,21 @@ struct Variant {
ok: Vec<bool>,
}
/// Most pixels the mosaic of the image and its variants may span. It is one dense rectangle, so
/// thin images turned to different angles leave it mostly empty: the cost grows with the square
/// of their length, not with their pixels.
const MAX_MOSAIC_PIXELS: usize = 1 << 25;
/// Size of the mosaic of an `w`×`h` image and `extra` variant sizes separated by `gap`, or `None`
/// when it spans more than [`MAX_MOSAIC_PIXELS`] (or overflows).
fn mosaic_size(w: usize, h: usize, gap: usize, extra: &[(usize, usize)]) -> Option<(usize, usize)> {
let col_w = extra.iter().map(|v| v.0).max().unwrap_or(0);
let col_h = extra.iter().try_fold(0usize, |n, v| n.checked_add(v.1)?.checked_add(gap))?;
let mw = w.checked_add(gap)?.checked_add(col_w)?;
let mh = h.max(col_h);
(mw.checked_mul(mh)? <= MAX_MOSAIC_PIXELS).then_some((mw, mh))
}
/// A source buffer and which of its pixels may be sampled.
#[derive(Clone, Copy)]
struct Src<'a> {
@@ -149,13 +164,20 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool {
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
/// `Err` when cancelled.
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
let Src { w, h, ch, .. } = src;
/// Dimensions of the [`variant`] copy: the same bounding-box maths without building its pixels.
fn variant_dims(w: usize, h: usize, deg: f32, scale: f32) -> (usize, usize) {
let (s, c) = (-deg.to_radians()).sin_cos();
// Output size: the rotated, scaled bounding box.
let (fw, fh) = (w as f32 * scale, h as f32 * scale);
let ow = ((fw * c.abs() + fh * s.abs()).round() as usize).max(1);
let oh = ((fw * s.abs() + fh * c.abs()).round() as usize).max(1);
(ow, oh)
}
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
let Src { w, h, ch, .. } = src;
// Output size: the rotated, scaled bounding box.
let (ow, oh) = variant_dims(w, h, deg, scale);
let (s, c) = (-deg.to_radians()).sin_cos();
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
@@ -243,17 +265,27 @@ fn fill_cow(
let gap = 2 * params.patch_radius + 2;
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
let src = Src { w, h, ch, img: &img, ok: &ok };
// Variants: the identity (which holds the hole) first, then the adaptations.
let mut extra: Vec<Variant> = Vec::new();
// Variants: the identity (which holds the hole) first, then the adaptations. Their pixels are
// expensive, so the mosaic budget is settled on their dimensions before any variant is built;
// when the variants would not fit, the image itself is completed without them.
let mut specs: Vec<(f32, f32, bool)> = Vec::new();
if opts.mirror {
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
specs.push((0.0, 1.0, true));
}
for &deg in &opts.rotations {
extra.push(variant(src, deg, 1.0, false, ctl)?);
specs.push((deg, 1.0, false));
}
if opts.scale {
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
specs.push((0.0, 0.8, false));
specs.push((0.0, 1.25, false));
}
let dims: Vec<(usize, usize)> = specs.iter().map(|&(deg, scale, _)| variant_dims(w, h, deg, scale)).collect();
let size = if specs.is_empty() { None } else { mosaic_size(w, h, gap, &dims) };
let mut extra: Vec<Variant> = Vec::new();
if size.is_some() {
for &(deg, scale, mirror) in &specs {
extra.push(variant(src, deg, scale, mirror, ctl)?);
}
}
let role = |s: usize| {
if hole[s] {
@@ -265,14 +297,9 @@ fn fill_cow(
}
};
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
// Without variants it is the image itself.
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
(w, h, None, par::map(w * h, true, role))
} else {
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
let mw = w + gap + col_w;
let mh = h.max(col_h);
// Without variants it is the image itself; so it is when the variants would not fit
// (`size` above is then None and `extra` is empty).
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if let Some((mw, mh)) = size {
let mut mimg = vec![0.0f32; mw * mh * ch];
let mut roles = vec![Px::Void; mw * mh];
for y in 0..h {
@@ -297,6 +324,8 @@ fn fill_cow(
oy += v.h + gap;
}
(mw, mh, Some(mimg), roles)
} else {
(w, h, None, par::map(w * h, true, role))
};
drop((extra, ok));
ctl.check()?;
@@ -695,6 +724,20 @@ pub fn default_brush_size(hole: Rect) -> f64 {
#[cfg(test)]
mod tests {
use super::mosaic_size;
fn blank(w: usize, h: usize) -> (usize, usize) {
(w, h)
}
#[test]
fn a_thin_image_with_turned_variants_does_not_get_a_quadratic_mosaic() {
// 40000×1 plus its 90° copy (1×40000): about 1.6 billion mosaic cells for 80000 pixels.
assert_eq!(mosaic_size(40_000, 1, 8, &[blank(1, 40_000)]), None);
assert_eq!(mosaic_size(usize::MAX, 1, 8, &[blank(1, 1)]), None);
assert_eq!(mosaic_size(100, 100, 8, &[blank(100, 100)]), Some((208, 108)));
}
use super::*;
/// Vertical stripes (period 6) in grey, with a hole in the middle.
+6 -1
View File
@@ -112,6 +112,10 @@ pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: Sph
})
}
/// The most Wave generators the renderer models: it clamps the setting to this, so a file asking
/// for more (the command allows up to 999) would render differently from how it was authored.
pub const MAX_WAVE_GENERATORS: u32 = 64;
/// Wave generator settings.
pub(crate) struct WaveSpec {
pub generators: u32,
@@ -137,8 +141,9 @@ fn shape(t: WaveType, phase: f32) -> f32 {
}
}
/// Wave: displace `src` over `out` by the average of `w.generators` seeded waves (at most `MAX_WAVE_GENERATORS`) along each axis.
pub(crate) fn wave(src: &Image, out: Rect, ctx: &Ctx, w: WaveSpec) -> Vec<f32> {
let n = w.generators.clamp(1, 64);
let n = w.generators.clamp(1, MAX_WAVE_GENERATORS);
let lerp = |(a, b): (f32, f32), t: f32| a + (b - a).max(0.0) * t;
let gens: Vec<(f32, f32, f32, f32)> = (0..n)
.map(|i| {
+52 -4
View File
@@ -87,6 +87,8 @@ impl Tiff<'_> {
}
}
fn text(&self, typ: u16, count: u32, field: usize) -> Option<String> {
#[cfg(test)]
TEXT_DECODES.with(|c| c.set(c.get() + 1));
if typ != 2 {
return None;
}
@@ -97,6 +99,9 @@ impl Tiff<'_> {
}
}
#[cfg(test)]
thread_local!(static TEXT_DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) });
/// Reads [`CameraInfo`] from a TIFF-structured EXIF payload (an `Exif\0\0` prefix is skipped).
pub fn read(exif: &[u8]) -> CameraInfo {
let b = exif.strip_prefix(b"Exif\0\0").unwrap_or(exif);
@@ -117,15 +122,17 @@ pub fn read(exif: &[u8]) -> CameraInfo {
seen.push(off);
for (tag, typ, count, field) in t.ifd(off) {
match tag {
0x010F => info.make = t.text(typ, count, field),
0x0110 => info.model = t.text(typ, count, field),
// Duplicate TIFF text tags can point at the same large payload. Keep the first
// recognized value and do not repeatedly decode/copy later duplicates.
0x010F if info.make.is_none() => info.make = t.text(typ, count, field),
0x0110 if info.model.is_none() => info.model = t.text(typ, count, field),
0x829A => info.exposure_time = t.number(typ, count, field),
0x829D => info.f_number = t.number(typ, count, field),
0x8827 => info.iso = t.number(typ, count, field),
0x920A => info.focal_length = t.number(typ, count, field),
0xA405 => info.focal_length_35mm = t.number(typ, count, field).filter(|v| *v > 0.0),
0xA434 => info.lens = t.text(typ, count, field),
0x9003 => info.date_taken = t.text(typ, count, field),
0xA434 if info.lens.is_none() => info.lens = t.text(typ, count, field),
0x9003 if info.date_taken.is_none() => info.date_taken = t.text(typ, count, field),
0x8769 => {
if let Some(p) = t.u32(field) {
queue.push(p as usize);
@@ -252,6 +259,47 @@ mod tests {
/// Hostile payloads: huge counts, offsets near `u32::MAX`, self-referencing IFDs and random
/// bytes read as `None` fields instead of panicking or overflowing (usize is 32 bits on wasm).
#[test]
fn duplicate_text_tags_keep_the_first_value_without_copying_large_duplicates() {
let value = vec![b'X'; 2 * 1024 * 1024];
let mut b = b"II*\0".to_vec();
b.extend_from_slice(&8u32.to_le_bytes());
// IFD0: Make="M", a duplicate Make pointing at a 2 MiB payload, and the Exif IFD link.
b.extend_from_slice(&3u16.to_le_bytes());
b.extend_from_slice(&0x010Fu16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&2u32.to_le_bytes());
b.extend_from_slice(b"M\0\0\0");
b.extend_from_slice(&0x010Fu16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&(value.len() as u32).to_le_bytes());
b.extend_from_slice(&80u32.to_le_bytes());
b.extend_from_slice(&0x8769u16.to_le_bytes());
b.extend_from_slice(&4u16.to_le_bytes());
b.extend_from_slice(&1u32.to_le_bytes());
b.extend_from_slice(&50u32.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes());
// Exif IFD at 50: DateTimeOriginal="D" and the same large duplicate payload at offset 80.
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&0x9003u16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&2u32.to_le_bytes());
b.extend_from_slice(b"D\0\0\0");
b.extend_from_slice(&0x9003u16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&(value.len() as u32).to_le_bytes());
b.extend_from_slice(&80u32.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes());
b.extend_from_slice(&value);
TEXT_DECODES.with(|c| c.set(0));
let info = read(&b);
assert_eq!(info.make.as_deref(), Some("M"));
assert_eq!(info.date_taken.as_deref(), Some("D"));
// One decode per tag: duplicates must be skipped before `text` copies their payload.
assert_eq!(TEXT_DECODES.with(|c| c.get()), 2);
}
#[test]
fn hostile_payloads_never_panic() {
let entry = |tag: u16, typ: u16, count: u32, value: u32| {
+57
View File
@@ -28,6 +28,18 @@ impl RgbHistogram {
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
// Camera Raw counts its whole proxy (~450k pixels) on every slider step, so the count
// runs in blocks across cores (#211, P36). Integer sums don't depend on the order the
// blocks finish in, so the result is the same as one pass over all pixels.
#[cfg(not(target_arch = "wasm32"))]
if pixels.len() >= PARALLEL_PIXELS {
use rayon::prelude::*;
return pixels.par_chunks(PARALLEL_PIXELS / 2).map(Self::count).reduce(Self::default, Self::merge);
}
Self::count(pixels)
}
fn count(pixels: &[[f32; 4]]) -> Self {
let mut h = Self::default();
for pixel in pixels {
if !pixel[3].is_finite() {
@@ -68,8 +80,29 @@ impl RgbHistogram {
}
h
}
/// Sum of two block counts. Saturating like the counters themselves.
#[cfg(not(target_arch = "wasm32"))]
fn merge(mut self, other: Self) -> Self {
let add = |a: &mut [u64], b: &[u64]| a.iter_mut().zip(b).for_each(|(a, b)| *a = a.saturating_add(*b));
for (a, b) in self.channels.iter_mut().zip(&other.channels) {
add(a, b);
}
add(&mut self.underflow, &other.underflow);
add(&mut self.overflow, &other.overflow);
add(&mut self.shadows, &other.shadows);
add(&mut self.highlights, &other.highlights);
self.samples = self.samples.saturating_add(other.samples);
self.transparent = self.transparent.saturating_add(other.transparent);
self.invalid = self.invalid.saturating_add(other.invalid);
self
}
}
/// Below this many pixels one pass is faster than splitting the count across cores.
#[cfg(not(target_arch = "wasm32"))]
const PARALLEL_PIXELS: usize = 64 * 1024;
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
/// Transparent and non-finite pixels clip nothing.
@@ -94,6 +127,30 @@ pub fn clipping(p: [f32; 4]) -> (u8, u8) {
mod tests {
use super::*;
// The parallel path, and its threshold, exist only off wasm32.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn matches_the_single_pass_on_every_domain_and_across_block_edges() {
// Every special value at every channel, plus a dense ramp through each bin's rounding
// edge; lengths around the parallel threshold and its block size.
let special = [0.0, -0.0, 1.0, -1e-7, 1.0 + 1e-7, 0.5 / 255.0, 254.5 / 255.0, -3.0, 4.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MIN_POSITIVE];
let mut pool: Vec<[f32; 4]> = Vec::new();
for (i, &v) in special.iter().enumerate() {
for alpha in [1.0, 0.5, 0.0, -1.0, f32::NAN] {
pool.push([v, special[(i + 3) % special.len()], 0.25, alpha]);
pool.push([0.75, v, special[(i + 5) % special.len()], alpha]);
}
}
pool.extend((0..4096).map(|i| {
let v = i as f32 / 4095.0 * 1.2 - 0.1;
[v, 1.0 - v, (v * 7.0).fract(), 1.0]
}));
for len in [0, 1, PARALLEL_PIXELS / 2 + 1, PARALLEL_PIXELS - 1, PARALLEL_PIXELS, 3 * PARALLEL_PIXELS + 17] {
let pixels: Vec<_> = (0..len).map(|i| pool[(i * 7919) % pool.len()]).collect();
assert_eq!(RgbHistogram::from_rgba(&pixels), RgbHistogram::count(&pixels), "{len} pixels");
}
}
#[test]
fn exact_bins_and_channel_totals() {
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
+12
View File
@@ -319,6 +319,10 @@ fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
/// Bounds of the hole pixels of a role grid.
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
// A zero-width grid holds no pixels whatever its height; don't size a row table by it.
if w == 0 || h == 0 {
return None;
}
// Per row the first and last hole column; the union of the rows' spans.
let rows: Vec<Option<(usize, usize)>> = par::map(h, w * h >= PAR_MIN, |y| {
let row = px.get(y * w..(y + 1) * w)?;
@@ -2047,6 +2051,14 @@ mod tests {
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
}
/// A zero-width image has no pixels however tall it is claimed to be: no per-row table.
#[test]
fn zero_width_images_complete_without_sizing_by_height() {
let p = CompleteParams::default();
assert_eq!(complete(0, 1 << 40, 3, &[], &[], &p), Some(Vec::new()));
assert_eq!(complete(1 << 40, 0, 3, &[], &[], &p), Some(Vec::new()));
}
/// Smooth gradient plus deterministic fine noise.
fn texture(w: usize, h: usize) -> Vec<f32> {
let mut rng = Rng::new(7);
+67 -19
View File
@@ -86,6 +86,7 @@ pub mod warp;
pub mod wideangle;
pub use artistic::{GALLERY_CATEGORIES, GalleryEffect, GalleryFilter, GalleryParam, GalleryParamKind};
pub use distort::MAX_WAVE_GENERATORS;
pub use image::{Edge, Image};
pub use params_ext::*;
@@ -560,7 +561,7 @@ pub enum FilterParams {
impl FilterParams {
/// Pixels read outside an output tile.
pub fn halo(&self) -> Halo {
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
match self {
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
@@ -570,9 +571,9 @@ impl FilterParams {
| FilterParams::Median { radius }
| FilterParams::DustAndScratches { radius, .. }
| FilterParams::Minimum { radius, .. }
| FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1),
| FilterParams::Maximum { radius, .. } => Halo::Radius((radius.max(0.0).ceil() as i32).saturating_add(1)),
FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2),
FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1),
FilterParams::Mosaic { cell_size } => Halo::Radius((cell_size.max(1.0).ceil() as i32).saturating_add(1)),
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
FilterParams::FindEdges => Halo::Radius(1),
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
@@ -697,7 +698,7 @@ impl FilterParams {
/// Halos of the second filter batch.
fn halo_ext(p: &FilterParams) -> Halo {
let r = |v: f32| Halo::Radius(v.max(0.0).ceil() as i32 + 1);
let r = |v: f32| Halo::Radius((v.max(0.0).ceil() as i32).saturating_add(1));
match p {
FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0),
FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0),
@@ -718,8 +719,9 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
FilterParams::SmartBlur { radius, .. } => r(*radius),
FilterParams::LensBlur { radius, .. } => r(*radius),
// The kernels clamp the radius to 100 px: size the source window by what they use.
FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)),
FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)),
FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)),
FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)),
FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))),
@@ -998,10 +1000,18 @@ pub fn apply_in_with(
}
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
/// re-read margin (and its cost) below ~2× the tile area.
/// re-read margin (and its cost) below ~2× the tile area. The radius is input-derived, so the
/// halo arithmetic is done in `i64` and the result capped: an `i32::MAX` radius must not
/// overflow in debug builds, and must not produce a tile bigger than the hard cap.
fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
match params.halo_for(bounds) {
Halo::Radius(r) => TILE.max((2 * r + 63) / 64 * 64).min(2048),
// The radius is input-derived: widen to i64 before the arithmetic and cap the
// conversion, so an i32::MAX radius cannot overflow (debug panic) and cannot make a
// tile bigger than the hard cap below (131_072 = 2048 * 64, one tile row).
Halo::Radius(r) => {
let halo = ((2 * i64::from(r) + 63) / 64 * 64).min(131_072);
TILE.max(halo as i32).min(2048)
}
Halo::Bounds => TILE,
}
}
@@ -1095,14 +1105,16 @@ fn apply_tiled_with_impl(
return None;
}
let halo = params.halo_for(bounds);
if matches!(params, FilterParams::UnsharpMask { .. }) {
let Halo::Radius(reach) = halo else { return None };
// Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is
// caller-supplied, so a short parameter set can ask for a huge source window per tile.
if let Halo::Radius(reach) = halo {
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
// Reject hostile source-window sizes before Image::read allocates them.
// Ordinary filter parameters and automatic tiles fit these bounds.
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || tile_count > 1_048_576 {
let too_many_tiles = matches!(params, FilterParams::UnsharpMask { .. }) && tile_count > 1_048_576;
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || too_many_tiles {
return None;
}
}
@@ -1130,16 +1142,43 @@ fn apply_tiled_with_impl(
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let run = |t: &Rect| -> (Rect, Vec<f32>) {
enum TileData {
Samples(Vec<f32>),
Encoded(Vec<u8>),
}
// Cell filters become writeback-bound once their geometry is reused. Encode
// on the tile workers; committing the group then only copies native bytes.
let finish = |data: Vec<f32>| {
if matches!(params, FilterParams::Crystallize { .. } | FilterParams::Pointillize { .. })
&& let Some(bytes) = pixelate::cells::encode(fmt, &data)
{
TileData::Encoded(bytes)
} else {
TileData::Samples(data)
}
};
let run = |t: &Rect| -> (Rect, TileData) {
// Cancelled: skip the remaining tiles of the group (the result is discarded).
if ctl.cancelled() {
return (*t, Vec::new());
return (*t, TileData::Samples(Vec::new()));
}
let tick = || {
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes after each group.
ctl.progress(0.98 * n as f32 / total as f32);
};
let sparse = match *params {
FilterParams::Crystallize { cell_size, seed } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, None, extent),
FilterParams::Pointillize { cell_size, seed, background } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, Some(background), extent),
_ => None,
};
if let Some((src, mut data)) = sparse {
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, &src, fmt.alpha);
}
tick();
return (*t, finish(data));
}
let owned;
let src = match (&shared, halo) {
(Some(s), _) => s,
@@ -1168,13 +1207,13 @@ fn apply_tiled_with_impl(
_ => Some(kernel(params, src, *t, &ctx)),
},
}) else {
return (*t, Vec::new());
return (*t, TileData::Samples(Vec::new()));
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src, fmt.alpha);
}
tick();
(*t, data)
(*t, finish(data))
};
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
// group is written before the next starts, so a huge layer never holds all its results as
@@ -1190,24 +1229,33 @@ fn apply_tiled_with_impl(
return None;
}
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<f32>)> = {
let results: Vec<(Rect, TileData)> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
let results: Vec<(Rect, TileData)> = chunk.iter().map(run).collect();
if ctl.cancelled() {
return None;
}
// Checked filters can reject malformed geometry or an allocation failure.
// Do not hand an incomplete tile to Surface::write_region.
if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. }))
&& results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels())
&& results.iter().any(|(t, data)| {
let pixels = t.width() as usize * t.height() as usize;
match data {
TileData::Samples(v) => v.len() != pixels * fmt.channels(),
TileData::Encoded(v) => v.len() != pixels * fmt.bytes_per_pixel(),
}
})
{
return None;
}
for (t, data) in results {
out.write_region(t, &data);
match data {
TileData::Samples(data) => out.write_region(t, &data),
TileData::Encoded(data) => out.write_interleaved(t, &data),
}
}
}
out.prune();
+88 -25
View File
@@ -191,6 +191,9 @@ struct StrokeCursor {
/// 125,000); smaller ones are not worth the hand-off.
const PARALLEL_DAB_NODES: usize = 16_384;
/// Bound completed output buffers independently of the document's size.
const RENDER_BATCH_TILES: usize = 64;
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
@@ -209,11 +212,20 @@ pub fn auto_cell(bounds: Rect) -> f64 {
if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 }
}
/// Most nodes of a field's displacement grid (12 bytes each with the freeze mask): 192 MB.
const MAX_FIELD_NODES: usize = 1 << 24;
impl LiquifyField {
pub fn new(bounds: Rect, cell: f64) -> Self {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
let mut cell = cell.clamp(1.0, 64.0);
let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (f64::from(bounds.height()) / cell).ceil() as usize + 2);
let (mut w, mut h) = nodes(cell);
// The extent is the document canvas, which a file declares: past the node budget the
// field gets coarser (it is interpolated anyway) instead of asking for gigabytes.
while w.saturating_mul(h) > MAX_FIELD_NODES {
cell *= 2.0;
(w, h) = nodes(cell);
}
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
}
@@ -584,14 +596,11 @@ impl LiquifyField {
}
/// Catmull-Rom (bicubic) displacement at grid coordinates.
fn sample_cubic(&self, gx: f64, gy: f64, wx: &[f64; 4], wy: &[f64; 4]) -> [f64; 2] {
let (ix, iy) = (gx.floor() as isize, gy.floor() as isize);
fn sample_cubic(&self, xs: &[(usize, f64); 4], ys: &[(usize, f64); 4]) -> [f64; 2] {
let mut acc = [0.0f64; 2];
for (jj, wyj) in wy.iter().enumerate() {
let y = (iy - 1 + jj as isize).clamp(0, self.h as isize - 1) as usize;
for &(y, wyj) in ys {
let row = &self.d[y * self.w..(y + 1) * self.w];
for (ii, wxi) in wx.iter().enumerate() {
let x = (ix - 1 + ii as isize).clamp(0, self.w as isize - 1) as usize;
for &(x, wxi) in xs {
let k = wxi * wyj;
acc[0] += f64::from(row[x][0]) * k;
acc[1] += f64::from(row[x][1]) * k;
@@ -651,6 +660,13 @@ fn catmull_rom(t: f64) -> [f64; 4] {
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
}
/// A field axis's clamped nodes and weights, reusable across a tile's rows or columns.
fn cubic_taps(g: f64, len: usize) -> [(usize, f64); 4] {
let i = g.floor() as isize;
let weights = catmull_rom(g - g.floor());
std::array::from_fn(|k| ((i - 1 + k as isize).clamp(0, len as isize - 1) as usize, weights[k]))
}
/// Resamples `src` through the field: inside `field.bounds` each pixel shows the source at
/// `p + d(p)` (bicubic field, bicubic premultiplied source, edge pixels repeat past the field
/// bounds); everything outside the bounds is kept. Same format as `src`.
@@ -671,19 +687,24 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
let h = t.height() as usize;
// Field per pixel (bicubic), rows share their y weights.
// Field per pixel (bicubic), with axis taps shared across rows and columns.
let mut disp = vec![[0.0f64; 2]; w * h];
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut moved = false;
let columns: Vec<_> = (0..w)
.map(|x| {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
(px, cubic_taps(gx, field.w))
})
.collect();
for y in 0..h {
let py = f64::from(t.y0) + y as f64 + 0.5;
let gy = (py - f64::from(b.y0) - 0.5) / field.cell;
let wy = catmull_rom(gy - gy.floor());
for x in 0..w {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
let wx = catmull_rom(gx - gx.floor());
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
let ys = cubic_taps(gy, field.h);
for (x, (px, xs)) in columns.iter().enumerate() {
let px = *px;
let mut d = field.sample_cubic(xs, &ys);
if has_freeze {
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
if mask > 0.0 {
@@ -765,15 +786,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
}
Some((*t, outp))
};
#[cfg(not(target_arch = "wasm32"))]
let done: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
tiles.par_iter().filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().filter_map(work).collect();
for (r, v) in done {
out.write_region(r, &v);
for batch in tiles.chunks(RENDER_BATCH_TILES) {
for (r, v) in par::map(batch.len(), true, |i| batch.get(i).and_then(work)).into_iter().flatten() {
out.write_region(r, &v);
}
}
if alpha {
out.prune();
@@ -1122,6 +1138,15 @@ mod tests {
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_huge_canvas_gets_a_coarser_field_not_a_huge_one() {
// 400000² px at 2 px per node is 4·10¹⁰ nodes.
let f = LiquifyField::new(Rect::new(0, 0, 400_000, 400_000), 2.0);
assert!(f.w * f.h <= MAX_FIELD_NODES && f.d.len() == f.w * f.h && f.freeze.len() == f.d.len());
assert!(f.cell > 64.0);
assert_eq!(LiquifyField::new(Rect::new(0, 0, 100, 100), 2.0).cell, 2.0);
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
@@ -1300,6 +1325,44 @@ mod tests {
}
}
#[test]
fn a_wide_integer_translation_keeps_channels_and_tile_edges_at_every_format() {
let b = Rect::new(13, -1, 13 + 70 * 256, 1);
let w = b.width() as usize;
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let format = PixelFormat::new(mode, depth, true);
let n = format.channels();
let mut data = Vec::new();
for i in 0..w * 2 {
let alpha = if i % 13 == 0 { 0.0 } else { 0.5 };
for c in 0..mode.color_channels() {
data.push(if alpha == 0.0 { 0.0 } else { ((i * 19 + c * 43) % 251 + 2) as f32 / 255.0 });
}
data.push(alpha);
}
let mut source = Surface::new(format);
source.write_region(b, &data);
source.write_pixel(b.x0 - 1, b.y0 - 1, &vec![0.75; n]);
let before = source.read_region(b);
let mut field = LiquifyField::new(b, 2.5);
field.d.fill([-1.0, 0.0]);
let output = apply_liquify(&source, &field);
let after = output.read_region(b);
let tolerance = if depth == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 };
for (original, moved) in before.chunks_exact(w * n).zip(after.chunks_exact(w * n)) {
for (x, pixel) in moved.chunks_exact(n).enumerate() {
let offset = x.saturating_sub(1) * n;
for (actual, expected) in pixel.iter().zip(&original[offset..offset + n]) {
assert!((actual - expected).abs() <= tolerance, "{mode:?}/{depth:?} at column {x}");
}
}
}
assert_eq!(output.pixel(b.x0 - 1, b.y0 - 1), source.pixel(b.x0 - 1, b.y0 - 1));
}
}
}
#[test]
fn no_strokes_is_identity_at_every_depth() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+5 -6
View File
@@ -512,12 +512,11 @@ fn direction_cost(tp: [f32; 2], tq: [f32; 2], dx: i32, dy: i32, len: f32) -> f32
if tp == [0.0; 2] || tq == [0.0; 2] {
return NEUTRAL_DIRECTION;
}
let mut l = [dx as f32 / len, dy as f32 / len];
if tp[0] * l[0] + tp[1] * l[1] < 0.0 {
l = [-l[0], -l[1]];
}
let dp = (tp[0] * l[0] + tp[1] * l[1]).clamp(-1.0, 1.0);
let dq = (l[0] * tq[0] + l[1] * tq[1]).clamp(-1.0, 1.0);
// An edge tangent is an axis, not a directed vector: reversing the strongest channel's
// gradient must not make an otherwise straight edge expensive to follow.
let l = [dx as f32 / len, dy as f32 / len];
let dp = (tp[0] * l[0] + tp[1] * l[1]).abs().clamp(0.0, 1.0);
let dq = (l[0] * tq[0] + l[1] * tq[1]).abs().clamp(0.0, 1.0);
2.0 / (3.0 * PI) * (dp.acos() + dq.acos())
}
+33
View File
@@ -55,6 +55,39 @@ fn snaps_to_the_most_prominent_edge() {
assert!((p[0] - 18.0).abs() < 0.01, "{p:?}");
}
#[test]
fn gradient_polarity_does_not_make_a_straight_edge_look_like_a_turn() {
// A gradient can reverse across adjacent pixels when another colour channel becomes the
// strongest one. The edge tangent is an axis, so its sign must not change the path cost.
let forward = direction_cost([1.0, 0.0], [1.0, 0.0], 1, 0, 1.0);
let reversed = direction_cost([1.0, 0.0], [-1.0, 0.0], 1, 0, 1.0);
assert!(forward < 1e-6, "aligned edge: {forward}");
assert!(reversed < 1e-6, "same edge with reversed gradient polarity: {reversed}");
}
#[test]
fn traces_a_straight_edge_through_a_gradient_polarity_change() {
let read = Cell::new(0);
// The contrast stays high, but the edge changes from dark-to-light to light-to-dark halfway
// down. The horizontal seam intersects it; the guide stays on the vertical contour.
let mut f = image(
|x, y| {
let left = x < 32;
let top = y < 32;
let white = left != top;
grey(if white { 255 } else { 0 })
},
&read,
);
let mut t = Tracer::new(Rect::new(0, 0, 64, 64));
let path = t.trace(&mut f, [32.0, 5.0], [32.0, 59.0], &[[32.0, 32.0]], Settings::default());
assert_eq!(path.first(), Some(&[32.0, 5.0]));
assert_eq!(path.last(), Some(&[32.0, 59.0]));
for p in &path {
assert!((p[0] - 32.0).abs() < 1.0, "left the high-contrast edge: {p:?}; {path:?}");
}
}
#[test]
fn trace_follows_a_curved_edge() {
let read = Cell::new(0);
+19 -4
View File
@@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec<f32>
mod brush;
mod morphology;
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area};
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_surface, painted_tiles, stroke_area, swept_area};
/// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman:
/// three comparisons per sample whatever the radius.
@@ -413,6 +413,22 @@ where
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
}
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
/// (about two bytes a pixel while it is assembled), so callers refuse a larger refine up front
/// rather than allocate it: 2³⁰ pixels, a 32k × 32k selection.
pub const MAX_REFINE_AREA: u64 = 1 << 30;
/// The area [`refine_mask`] and [`refine_mask_brushed`] compute and allocate their result over:
/// `content` grown by how far `p` can spread it, plus where `brush` adds, within `canvas`. Empty
/// when `content` misses the canvas.
pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>) -> Rect {
let content = content.intersect(&canvas);
if content.is_empty() {
return Rect::EMPTY;
}
content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas)
}
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
@@ -446,11 +462,10 @@ fn refine_mask_with<F>(
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy + Sync + Send,
{
let content = content.intersect(&canvas);
if content.is_empty() {
let bbox = refine_area(content, canvas, p, brush);
if bbox.is_empty() {
return None;
}
let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas);
let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX));
let mut tiles = Vec::new();
let mut y = bbox.y0;
+259 -82
View File
@@ -10,10 +10,17 @@
//! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution
//! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window.
use photocraft_geom::Rect;
use std::collections::{BTreeMap, BTreeSet};
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::Surface;
use super::box_mean;
use crate::segment::RgbImage;
use crate::selection::{MAX_PAINTED_TILES, clip_segment};
const TS: usize = TILE_SIZE as usize;
/// One Refine Edge Brush stroke, in document pixels.
#[derive(Clone, Debug, PartialEq)]
@@ -29,26 +36,42 @@ pub struct EdgeStroke {
/// Brush diameters the strokes may use.
pub const SIZE_RANGE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`.
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched), kept only for
/// the tiles the strokes paint, so a thin stroke across a huge canvas costs those tiles, not its
/// bounding box (#8783).
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeBrush {
/// Where the strokes can paint.
bbox: Rect,
/// Coverage × 127, row-major over `bbox`.
v: Vec<i8>,
/// Coverage × 127 per painted tile, row-major over the tile.
tiles: BTreeMap<TileCoord, Vec<i8>>,
/// Bounds of the added (positive) coverage.
added: Rect,
/// Box radius of the colour estimate (pixels).
window: usize,
}
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
/// Distance from pixel centre `p` to the segment from `a` to `b`.
fn segment_distance(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);
let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt();
(r + 0.5 - d).clamp(0.0, 1.0)
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
}
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
(r + 0.5 - segment_distance(p, a, b)).clamp(0.0, 1.0)
}
/// Coverage (0–1) at distance `d` from a brush's path, for radius `r` and a solid core of radius
/// `core` (hardness × `r`): full inside the core, then a smooth fall to zero half a pixel past `r`
/// (a hard brush keeps just the one-pixel anti-aliased rim).
fn soft_coverage(d: f32, r: f32, core: f32) -> f32 {
let ramp = r + 0.5 - core;
let t = ((r + 0.5 - d) / ramp.max(1.0)).clamp(0.0, 1.0);
if ramp > 1.0 { t * t * (3.0 - 2.0 * t) } else { t }
}
/// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle.
@@ -64,27 +87,113 @@ pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64;
/// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite.
pub fn swept_area(strokes: &[EdgeStroke]) -> f64 {
strokes
.iter()
.map(|s| {
let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
})
.sum()
strokes.iter().map(|s| stroke_area(&s.points, s.size)).sum()
}
/// The stroke's path as short segments: points closer than a quarter of the radius to the last
/// kept one are dropped, and long segments are split at most `r` long, so each segment's box
/// stays close to the area it paints.
fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
let Some(&first) = points.first() else { return Vec::new() };
/// The area one stroke of `points` sweeps with a brush `size` wide: length × size + size²;
/// non-finite strokes count as infinite.
pub fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 {
let size = f64::from(size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
let len: f64 = points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
}
/// The tiles of `canvas` a round brush `size` px wide along `points` can paint, in order; `None`
/// when that is more than `limit` (a thin stroke sweeps little but touches a whole tile wherever
/// it passes, so this, not [`MAX_SWEPT_AREA`], bounds what painting it allocates; #8783).
pub fn painted_tiles(points: &[(f32, f32)], size: f32, canvas: Rect, limit: usize) -> Option<BTreeSet<TileCoord>> {
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let mut set = BTreeSet::new();
// Consecutive pieces mostly fall in the same tiles.
let mut last: Option<Rect> = None;
for (a, b) in segments(points, r, canvas) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas);
// The tile coordinates `seg` spans (inclusive).
let span =
Rect::new(seg.x0.div_euclid(TILE_SIZE), seg.y0.div_euclid(TILE_SIZE), (seg.x1 - 1).div_euclid(TILE_SIZE), (seg.y1 - 1).div_euclid(TILE_SIZE));
if seg.is_empty() || last == Some(span) {
continue;
}
last = Some(span);
for tc in seg.tiles() {
set.insert(tc);
if set.len() > limit {
return None;
}
}
}
Some(set)
}
/// One stroke's coverage (0–255) per tile of `canvas`: the most `cov(pixel centre, a, b)` gives
/// over the path's pieces, so a stroke never paints past full. Only painted tiles are kept;
/// `None` past `limit` tiles (see [`painted_tiles`]).
fn paint_tiles(
points: &[(f32, f32)],
size: f32,
canvas: Rect,
limit: usize,
cov: impl Fn((f32, f32), (f32, f32), (f32, f32)) -> f32,
) -> Option<BTreeMap<TileCoord, Vec<u8>>> {
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let mut tiles: BTreeMap<TileCoord, Vec<u8>> = painted_tiles(points, size, canvas, limit)?.into_iter().map(|tc| (tc, vec![0u8; TS * TS])).collect();
for (a, b) in segments(points, r, canvas) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas);
for tc in seg.tiles() {
let o = tc.rect();
let Some(buf) = tiles.get_mut(&tc) else { continue };
let tr = o.intersect(&seg);
for y in tr.y0..tr.y1 {
for x in tr.x0..tr.x1 {
let c = (cov((x as f32 + 0.5, y as f32 + 0.5), a, b).clamp(0.0, 1.0) * 255.0).round() as u8;
if let Some(slot) = buf.get_mut((y - o.y0) as usize * TS + (x - o.x0) as usize)
&& c > *slot
{
*slot = c;
}
}
}
}
}
tiles.retain(|_, b| b.iter().any(|v| *v > 0));
Some(tiles)
}
/// Select and Mask's Brush: the coverage one stroke of a round brush `size` px wide with
/// `hardness` (0–100) paints along `points`, clipped to `canvas`, as a GRAY8 selection holding
/// only the tiles it paints. Overlapping dabs keep the most coverage any of them gives, so a
/// stroke never paints past full. `None` when it paints nothing, has non-finite points, sweeps
/// more than [`MAX_SWEPT_AREA`] or paints more than [`MAX_PAINTED_TILES`] tiles.
pub fn brush_surface(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option<Surface> {
if points.is_empty() || !size.is_finite() || !hardness.is_finite() || points.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
return None;
}
if stroke_area(points, size) > MAX_SWEPT_AREA {
return None;
}
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let core = r * hardness.clamp(0.0, 100.0) / 100.0;
let tiles = paint_tiles(points, size, canvas, MAX_PAINTED_TILES, |p, a, b| soft_coverage(segment_distance(p, a, b), r, core))?;
let mut s = Surface::new(PixelFormat::GRAY8);
for (tc, bytes) in &tiles {
s.write_interleaved(tc.rect(), bytes);
}
(s.tile_count() > 0).then_some(s)
}
/// The stroke's path as short segments within reach of `clip`: points closer than a quarter of
/// the radius to the last kept one are dropped, each segment is cut to `clip` grown by the brush
/// (what lies beyond can't paint inside it), and long ones are split at most `r` long, so each
/// piece's box stays close to the area it paints. The pieces are made as they are painted, never
/// collected, and only where they can paint: a 1 px stroke zigzagging across 2 million px is over
/// 100 million pieces in all, within [`MAX_SWEPT_AREA`] (the #8783 review).
fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator<Item = ((f32, f32), (f32, f32))> {
let min_step = (r / 4.0).max(0.5);
let max_step = r.max(2.0);
let mut kept = vec![first];
for p in points.iter().skip(1) {
let last = kept.last().copied().unwrap_or(first);
if (p.0 - last.0).hypot(p.1 - last.1) >= min_step {
let reach = clip.inflate((r + 2.0).ceil().clamp(0.0, 1e7) as i32);
let mut kept: Vec<(f32, f32)> = Vec::new();
for p in points {
if kept.last().is_none_or(|l| (p.0 - l.0).hypot(p.1 - l.1) >= min_step) {
kept.push(*p);
}
}
@@ -93,25 +202,22 @@ fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
{
kept.push(end);
}
if kept.len() == 1 {
return vec![(first, first)];
}
let mut out = Vec::new();
for w in kept.windows(2) {
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
// A single point is one dab.
let dab = if kept.len() == 1 { kept.first().map(|p| (*p, *p)) } else { None };
let pairs: Vec<((f32, f32), (f32, f32))> = kept.windows(2).filter_map(|w| clip_segment(*w.first()?, *w.get(1)?, reach)).collect();
dab.into_iter().chain(pairs.into_iter().flat_map(move |(a, b)| {
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize;
let at = |k: usize| {
let at = move |k: usize| {
let t = k as f32 / pieces as f32;
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
};
out.extend((0..pieces).map(|k| (at(k), at(k + 1))));
}
out
(0..pieces).map(move |k| (at(k), at(k + 1)))
}))
}
impl EdgeBrush {
/// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more
/// than [`MAX_SWEPT_AREA`].
/// Rasterises `strokes` (clipped to `canvas`), tile by tile. `None` when they paint nothing,
/// sweep more than [`MAX_SWEPT_AREA`] or paint more than [`MAX_PAINTED_TILES`] tiles.
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite());
if swept_area(strokes) > MAX_SWEPT_AREA {
@@ -126,61 +232,43 @@ impl EdgeBrush {
if bbox.is_empty() {
return None;
}
let bw = bbox.width() as usize;
let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?];
let mut cov: Vec<f32> = Vec::new();
let mut tiles: BTreeMap<TileCoord, Vec<i8>> = BTreeMap::new();
let mut max_add = 0.0f32;
for s in strokes {
let sb = stroke_bounds(s);
if sb.is_empty() {
continue;
}
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
if s.add {
max_add = max_add.max(2.0 * r);
}
let sw = sb.width() as usize;
cov.clear();
cov.resize(sw * sb.height() as usize, 0.0);
// The stroke's coverage: the most any of its segments gives (a single dab for one point).
for (a, b) in segments(&s.points, r) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb);
for y in seg.y0..seg.y1 {
for x in seg.x0..seg.x1 {
let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r);
if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize)
&& c > *slot
{
*slot = c;
}
}
}
}
let cov = paint_tiles(&s.points, s.size, canvas, MAX_PAINTED_TILES, |p, a, b| capsule(p, a, b, r))?;
let target = if s.add { 1.0 } else { -1.0 };
for y in sb.y0..sb.y1 {
for x in sb.x0..sb.x1 {
let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0);
if c <= 0.0 {
continue;
}
if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) {
let cur = f32::from(*slot) / 127.0;
for (tc, c) in cov {
let v = tiles.entry(tc).or_insert_with(|| vec![0i8; TS * TS]);
for (slot, c) in v.iter_mut().zip(c) {
if c > 0 {
let (cur, c) = (f32::from(*slot) / 127.0, f32::from(c) / 255.0);
*slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8;
}
}
}
if tiles.len() > MAX_PAINTED_TILES {
return None;
}
}
if v.iter().all(|c| *c == 0) {
tiles.retain(|_, v| v.iter().any(|c| *c != 0));
if tiles.is_empty() {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for (row, line) in v.chunks_exact(bw).enumerate() {
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
let y = bbox.y0 + row as i32;
(x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1));
let mut added = Rect::EMPTY;
for (tc, v) in &tiles {
let o = tc.rect();
for (row, line) in v.as_chunks::<TS>().0.iter().enumerate() {
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
let y = o.y0 + row as i32;
added = added.union(&Rect::new(o.x0 + first as i32, y, o.x0 + last as i32 + 1, y + 1));
}
}
let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY };
Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
Some(EdgeBrush { bbox, tiles, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
}
/// Where the strokes paint (added or restored).
@@ -200,16 +288,32 @@ impl EdgeBrush {
/// Signed coverage at document pixel `(x, y)` (zero outside the painted area).
pub fn at(&self, x: i32, y: i32) -> f32 {
if !self.bbox.contains(x, y) {
return 0.0;
}
let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize;
self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0)
let (tc, o) = (TileCoord::containing(x, y), (y.rem_euclid(TILE_SIZE) as usize) * TS + x.rem_euclid(TILE_SIZE) as usize);
self.tiles.get(&tc).and_then(|v| v.get(o)).map_or(0.0, |c| f32::from(*c) / 127.0)
}
/// Signed coverage over `r` (row-major).
pub fn read(&self, r: Rect) -> Vec<f32> {
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect()
let w = r.width() as usize;
let mut out = vec![0.0f32; w * r.height() as usize];
for tc in r.tiles() {
let Some(v) = self.tiles.get(&tc) else { continue };
let (o, tr) = (tc.rect(), tc.rect().intersect(&r));
for y in tr.y0..tr.y1 {
let src = (y - o.y0) as usize * TS;
let dst = (y - r.y0) as usize * w;
let (Some(src), Some(dst)) = (
v.get(src + (tr.x0 - o.x0) as usize..src + (tr.x1 - o.x0) as usize),
out.get_mut(dst + (tr.x0 - r.x0) as usize..dst + (tr.x1 - r.x0) as usize),
) else {
continue;
};
for (d, c) in dst.iter_mut().zip(src) {
*d = f32::from(*c) / 127.0;
}
}
}
out
}
}
@@ -276,6 +380,10 @@ mod tests {
use crate::segment::ImageSampler;
use crate::selection::Region;
fn at(s: &Surface, x: i32, y: i32) -> f32 {
s.pixel(x, y).first().copied().unwrap_or(0.0)
}
/// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's
/// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was.
#[test]
@@ -330,4 +438,73 @@ mod tests {
let empty = EdgeStroke { points: vec![], size: 10.0, add: true };
assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none());
}
#[test]
fn brush_surface_paints_hard_and_soft_strokes() {
let canvas = Rect::new(0, 0, 100, 50);
let hard = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted");
assert!(at(&hard, 50, 25) > 0.99 && at(&hard, 50, 33) > 0.99, "solid inside the radius");
assert!(at(&hard, 50, 36) == 0.0 && at(&hard, 50, 10) == 0.0, "nothing past it");
// A soft brush fades from the centre line to the rim.
let soft = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted");
let (centre, mid, rim) = (at(&soft, 50, 25), at(&soft, 50, 30), at(&soft, 50, 34));
assert!(centre > 0.95 && mid > 0.2 && mid < 0.8 && rim < 0.1, "{centre} {mid} {rim}");
// A single point is one dab; clipped to the canvas.
let dab = brush_surface(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted");
let b = dab.content_bounds();
assert_eq!((b.x0, b.y0), (0, 0));
assert!(at(&dab, 1, 1) > 0.99);
}
#[test]
fn brush_surface_paints_nothing_for_hostile_strokes() {
let canvas = Rect::new(0, 0, 100, 50);
assert!(brush_surface(&[], 10.0, 100.0, canvas).is_none());
assert!(brush_surface(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none());
assert!(brush_surface(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none());
assert!(brush_surface(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none());
assert!(brush_surface(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas");
assert!(brush_surface(&[(0.0, 0.0), (1e7, 1e7)], 5000.0, 100.0, canvas).is_none(), "sweeps too much");
// Sizes and hardness outside their ranges are clamped, not rejected.
assert!(brush_surface(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some());
}
/// A 1 px stroke zigzagging across 2 million px stays within [`MAX_SWEPT_AREA`] yet splits
/// into over 100 million pieces: painting it must not collect them all first (it allocated
/// about 2 GB up front; the #8783 review).
#[test]
fn a_long_thin_zigzag_paints_without_collecting_its_pieces() {
let canvas = Rect::new(0, 0, 64, 64);
let points: Vec<(f32, f32)> = (0..135).map(|i| (if i % 2 == 0 { 0.0 } else { 2_000_000.0 }, 32.0 + (i % 3) as f32)).collect();
assert!(stroke_area(&points, 1.0) <= MAX_SWEPT_AREA);
// Only the pieces within reach of the canvas are made.
assert!(segments(&points, 0.5, canvas).count() < 134 * 40, "{}", segments(&points, 0.5, canvas).count());
let r = brush_surface(&points, 1.0, 100.0, canvas).expect("it paints its row");
assert!(at(&r, 10, 32) > 0.5);
let strokes = [EdgeStroke { points, size: 1.0, add: true }];
assert!(EdgeBrush::new(&strokes, canvas).is_some_and(|b| b.at(10, 32) > 0.5));
}
/// A 1 px diagonal across a 100,000 px canvas sweeps about 141,000 px, well within
/// [`MAX_SWEPT_AREA`], but its bounding box is 10 billion pixels: painting it must cost the
/// tiles it crosses, not that box (it allocated 40 GB; the #8783 review).
#[test]
fn a_thin_diagonal_across_a_huge_canvas_paints_only_its_tiles() {
let canvas = Rect::new(0, 0, 100_000, 100_000);
let diagonal = [(0.0, 0.0), (99_999.0, 99_999.0)];
assert!(stroke_area(&diagonal, 1.0) <= MAX_SWEPT_AREA);
let tiles = painted_tiles(&diagonal, 1.0, canvas, MAX_PAINTED_TILES).expect("within the limit");
assert!(tiles.len() < 1500, "{}", tiles.len());
let s = brush_surface(&diagonal, 1.0, 100.0, canvas).expect("painted");
assert!(s.tile_count() <= tiles.len());
assert!(at(&s, 50_000, 50_000) > 0.5 && at(&s, 50_000, 40_000) == 0.0);
let strokes = [EdgeStroke { points: diagonal.to_vec(), size: 1.0, add: true }];
let b = EdgeBrush::new(&strokes, canvas).expect("painted");
assert!(b.at(50_000, 50_000) > 0.5 && b.at(50_000, 40_000) == 0.0);
assert!(b.read(Rect::new(49_990, 49_990, 50_010, 50_010)).iter().any(|v| *v > 0.5));
// A zigzag crossing more tiles than the limit is refused rather than allocated.
let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect();
assert!(stroke_area(&zigzag, 1.0) <= MAX_SWEPT_AREA);
assert!(painted_tiles(&zigzag, 1.0, canvas, MAX_PAINTED_TILES).is_none());
assert!(brush_surface(&zigzag, 1.0, 100.0, canvas).is_none());
assert!(EdgeBrush::new(&[EdgeStroke { points: zigzag, size: 1.0, add: true }], canvas).is_none());
}
}
+77 -3
View File
@@ -29,6 +29,24 @@ pub(crate) struct OilSpec {
fn stroke_len(stylization: f32) -> f32 {
1.5 + stylization.clamp(0.1, 10.0) * 2.0
}
/// `f32::round` (half away from zero) as an integer, without the `roundf` call `f32::round`
/// makes on the baseline x86-64 target. The sum is taken in `f64`, where `v ± 0.5` is exact for
/// every `f32` the cast does not saturate (an `f32` sum is not: `0.49999997 + 0.5` rounds up to
/// `1.0`), so the result equals `v.round() as i32` for every `f32`, NaN (0) and the saturating
/// ends included.
#[inline]
fn round_i32(v: f32) -> i32 {
let v = f64::from(v);
(v + 0.5f64.copysign(v)) as i32
}
/// [`round_i32`] clamped into `0..n`.
#[inline]
fn round_index(v: f32, n: usize) -> usize {
round_i32(v).clamp(0, n as i32 - 1) as usize
}
fn tensor_sigma(scale: f32) -> f32 {
0.8 + scale.clamp(0.1, 10.0) * 0.6
}
@@ -93,7 +111,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let sample = |x: f32, y: f32, acc: &mut [f32], k: f32| {
let x = x.clamp(0.0, (ww - 1) as f32);
let y = y.clamp(0.0, (wh - 1) as f32);
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
let (x0, y0) = (x as usize, y as usize);
let (x1, y1) = ((x0 + 1).min(ww - 1), (y0 + 1).min(wh - 1));
let (ax, ay) = (x - x0 as f32, y - y0 as f32);
let w00 = (1.0 - ax) * (1.0 - ay) * k;
@@ -119,7 +137,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let (mut qx, mut qy) = (dx, dy);
let (mut px, mut py) = (0.0f32, 0.0f32);
for k in 1..=steps {
let ti = (qy.round().clamp(0.0, (wh - 1) as f32) as usize) * ww + qx.round().clamp(0.0, (ww - 1) as f32) as usize;
let ti = round_index(qy, wh) * ww + round_index(qx, ww);
let (mut tx, mut ty) = tangent[ti];
if k == 1 {
tx *= dir;
@@ -135,7 +153,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let wk = 1.0 - k as f32 / (steps + 1) as f32;
sample(qx, qy, &mut acc[..n], wk);
wsum += wk;
nsum += wk * hash01(win.x0 + qx.round() as i32, win.y0 + qy.round() as i32, 71, 0);
nsum += wk * hash01(win.x0 + round_i32(qx), win.y0 + round_i32(qy), 71, 0);
}
}
let o = (ry * rw + rx) * n;
@@ -197,3 +215,59 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
}
res
}
#[cfg(test)]
mod tests {
use super::{round_i32, round_index};
/// The LIC looks up tangents and bristle noise by rounded sample position; the replacements
/// for `f32::round` must agree with it over every position a tile can produce, and agree with
/// `f32::clamp` at the ends, including non-finite input.
#[test]
fn rounding_matches_the_standard_library() {
let mut v = -40.0f32;
while v <= 2100.0 {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v})");
for n in [1usize, 2, 33, 256, 2048] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v}, {n})");
}
v += 0.25;
}
// The binade crossings where an `f32` sum `v + 0.5` rounds up (0.5 - 2^-25), the ties, and
// the largest non-integer `f32`.
let below_half = f32::from_bits(0x3eff_ffff);
for v in [below_half, -below_half, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 8_388_607.5, -8_388_607.5, 8_388_608.0, 3.0e9, -3.0e9] {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v:e})");
for n in [1usize, 2, 33] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v:e}, {n})");
}
}
assert_eq!(round_i32(f32::NAN), f32::NAN.round() as i32);
for n in [1usize, 8] {
assert_eq!(round_index(f32::NEG_INFINITY, n), (f32::NEG_INFINITY.clamp(0.0, (n - 1) as f32)) as usize);
assert_eq!(round_index(f32::INFINITY, n), ((n - 1) as f32) as usize);
}
}
/// Every `f32` bit pattern in [-4096, 4096] (both signs, about 2.3e9 values, split across
/// threads) rounds exactly like `f32::round`.
#[test]
fn rounding_matches_the_standard_library_for_every_f32_up_to_4096() {
use rayon::prelude::*;
let last = 4096.0f32.to_bits();
let bad = (0..=last >> 20)
.into_par_iter()
.filter_map(|hi| {
let lo = hi << 20;
let hi_end = (lo | 0xf_ffff).min(last);
(lo..=hi_end).find_map(|bits| {
let v = f32::from_bits(bits);
[v, -v].into_iter().find(|v| round_i32(*v) != v.round() as i32)
})
})
.min_by(|a, b| a.abs().total_cmp(&b.abs()));
assert_eq!(bad, None, "round_i32 differs from f32::round");
}
}
+11 -2
View File
@@ -5,6 +5,7 @@
use photocraft_geom::Rect;
pub(crate) mod cells;
mod facet;
#[cfg(test)]
mod reference;
@@ -120,7 +121,7 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
/// being distressed) keeps hard edges and its weight instead of greying where cells straddle
/// an edge. A cell whose point falls outside the image takes the (premultiplied) average
/// colour of its pixels.
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
fn crystallize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
let b = ctx.bounds;
@@ -320,7 +321,7 @@ pub(crate) fn mezzotint(src: &Image, out: Rect, ctx: &Ctx, kind: MezzotintType,
/// Pointillize: randomly sized dots, coloured from the image at their centre,
/// on a canvas of the background colour.
pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
fn pointillize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
let b = ctx.bounds;
@@ -374,3 +375,11 @@ pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
}
res
}
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
cells::crystallize(src, out, ctx, cell_size, seed).unwrap_or_else(|| crystallize_direct(src, out, ctx, cell_size, seed))
}
pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
cells::pointillize(src, out, ctx, cell_size, seed, background).unwrap_or_else(|| pointillize_direct(src, out, ctx, cell_size, seed, background))
}
+571
View File
@@ -0,0 +1,571 @@
//! Reuse the jittered lattice in cell space. Raster work stays in the original
//! row/candidate order, including f32 ties and boundary-cell average additions.
use super::*;
#[derive(Clone, Copy)]
struct Site {
point: (f32, f32),
color: [f32; MAXC],
radius: f32,
sampled: bool,
}
struct Grid {
x0: i32,
y0: i32,
width: usize,
sites: Vec<Site>,
}
fn key(p: i32, origin: f32, cell: f32) -> i32 {
((p as f32 + 0.5 - origin) / cell).floor() as i32
}
impl Grid {
fn new(src: &Image, area: Rect, ctx: &Ctx, cell: f32, seed: u32, dots: bool) -> Option<Self> {
if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(src.ch)? {
return None;
}
Self::new_with(area, ctx, src.ch, cell, seed, dots, |sx, sy, color| {
for (c, value) in color.iter_mut().enumerate() {
*value = src.get(sx, sy, c);
}
src.rect.contains(sx, sy) && ctx.bounds.contains(sx, sy)
})
}
#[allow(clippy::too_many_arguments)]
fn new_with(area: Rect, ctx: &Ctx, n: usize, cell: f32, seed: u32, dots: bool, sample: impl Fn(i32, i32, &mut [f32]) -> bool) -> Option<Self> {
if area.is_empty() || !cell.is_finite() || cell < 2.0 || n == 0 || n > MAXC {
return None;
}
let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32);
let x0 = key(area.x0, origin.0, cell).checked_sub(1)?;
let y0 = key(area.y0, origin.1, cell).checked_sub(1)?;
let x1 = key(area.x1.checked_sub(1)?, origin.0, cell).checked_add(1)?;
let y1 = key(area.y1.checked_sub(1)?, origin.1, cell).checked_add(1)?;
let width = usize::try_from(x1.checked_sub(x0)?.checked_add(1)?).ok()?;
let height = usize::try_from(y1.checked_sub(y0)?.checked_add(1)?).ok()?;
let len = width.checked_mul(height)?;
// Optional tile-local scratch, at most 16 MiB even for a direct kernel call.
if len.checked_mul(std::mem::size_of::<Site>())? > 16 * 1024 * 1024 {
return None;
}
let mut sites = Vec::new();
sites.try_reserve_exact(len).ok()?;
let cc = ncol(ctx, n);
let sub = subtractive(ctx);
for gy in y0..=y1 {
for gx in x0..=x1 {
let point = cell_point(gx, gy, cell, seed, origin);
let (sx, sy) = (point.0.floor() as i32, point.1.floor() as i32);
let mut color = [0.0; MAXC];
let sampled = sample(sx, sy, color.get_mut(..n)?);
let radius = if dots {
let j = (hash01(gx, gy, 17, seed) - 0.5) * 0.08;
for value in color.iter_mut().take(cc) {
*value = (*value + if sub { -j } else { j }).clamp(0.0, 1.0);
}
cell * (0.45 + 0.35 * hash01(gx, gy, 13, seed))
} else {
0.0
};
sites.push(Site { point, color, radius, sampled });
}
}
Some(Self { x0, y0, width, sites })
}
fn neighbours(&self, cx: i32, cy: i32) -> Option<[usize; 9]> {
let x = usize::try_from(cx.checked_sub(1)?.checked_sub(self.x0)?).ok()?;
let y = usize::try_from(cy.checked_sub(1)?.checked_sub(self.y0)?).ok()?;
if x.checked_add(2)? >= self.width {
return None;
}
let start = y.checked_mul(self.width)?.checked_add(x)?;
let last = start.checked_add(2 * self.width)?.checked_add(2)?;
self.sites.get(last)?;
Some(std::array::from_fn(|i| start + i / 3 * self.width + i % 3))
}
}
/// Contiguous x runs with the same base cell. Evaluate the old f32 mapping once
/// per column, rather than use an inverse mapping that could round differently.
fn runs(area: Rect, origin: f32, cell: f32) -> Vec<(i32, i32, i32)> {
let mut runs: Vec<(i32, i32, i32)> = Vec::new();
for x in area.x0..area.x1 {
let cx = key(x, origin, cell);
if let Some(last) = runs.last_mut()
&& last.0 == cx
{
last.2 = x + 1;
} else {
runs.push((cx, x, x + 1));
}
}
runs
}
fn nearest(grid: &Grid, neighbours: &[usize; 9], x: i32, y: i32) -> Option<usize> {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let mut best = (f32::MAX, *neighbours.get(4)?);
for &i in neighbours {
let p = grid.sites.get(i)?.point;
let d = (p.0 - fx).powi(2) + (p.1 - fy).powi(2);
if d < best.0 {
best = (d, i);
}
}
Some(best.1)
}
/// Bounds on the *same f32 score* used by the scalar comparison. Along a
/// scanline, squared x distance has its maximum at an endpoint and its lower
/// bound at the clamped site centre. IEEE subtraction, square, addition and
/// sqrt are monotone on these nonnegative inputs, so these bounds also cover
/// rounding. No estimated bisectors or epsilon decide ownership.
fn score_range<const DOTS: bool>(site: &Site, x0: i32, x1: i32, y: i32) -> (f32, f32) {
let left = x0 as f32 + 0.5;
let right = (x1 - 1) as f32 + 0.5;
let dy2 = (site.point.1 - (y as f32 + 0.5)).powi(2);
let near = (site.point.0 - site.point.0.clamp(left, right)).powi(2) + dy2;
let far = (site.point.0 - left).powi(2).max((site.point.0 - right).powi(2)) + dy2;
if DOTS { (site.radius - far.sqrt(), site.radius - near.sqrt()) } else { (-far, -near) }
}
fn score<const DOTS: bool>(site: &Site, x: i32, y: i32) -> f32 {
let d = (site.point.0 - (x as f32 + 0.5)).powi(2) + (site.point.1 - (y as f32 + 0.5)).powi(2);
if DOTS { site.radius - d.sqrt() } else { -d }
}
/// Certify a whole run when one candidate's worst score beats every other's
/// best. Ambiguous runs split down to scalar pixels. Depth is bounded by the
/// output tile width (at most 11 splits for a 2048-pixel application tile).
fn certified_span<const DOTS: bool>(grid: &Grid, ids: &[usize; 9], x0: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
let len = owners.len();
if len == 0 {
return Some(());
}
let x1 = x0.checked_add(i32::try_from(len).ok()?)?;
let middle = x0 + (len / 2) as i32;
let mut best = (if DOTS { -0.5 } else { -f32::MAX }, None);
for &i in ids {
let value = score::<DOTS>(grid.sites.get(i)?, middle, y);
if value > best.0 {
best = (value, Some(i));
}
}
if let Some(i) = best.1 {
let site = grid.sites.get(i)?;
let lower = score_range::<DOTS>(site, x0, x1, y).0;
let certified = (!DOTS || lower > -0.5)
&& ids.iter().filter(|&&j| j != i).all(|&j| grid.sites.get(j).is_some_and(|other| lower > score_range::<DOTS>(other, x0, x1, y).1));
if len == 1 || certified {
owners.fill(i as u32);
if DOTS {
if lower >= 0.5 {
depths.fill(0.5);
} else {
for (offset, depth) in depths.iter_mut().enumerate() {
*depth = score::<true>(site, x0 + offset as i32, y);
}
}
}
return Some(());
}
} else if len == 1 || ids.iter().all(|&i| grid.sites.get(i).is_some_and(|site| score_range::<DOTS>(site, x0, x1, y).1 <= -0.5)) {
owners.fill(u32::MAX);
return Some(());
}
let half = len / 2;
let (left, right) = owners.split_at_mut(half);
let (ld, rd) = depths.split_at_mut(half);
certified_span::<DOTS>(grid, ids, x0, y, left, ld)?;
certified_span::<DOTS>(grid, ids, x0 + half as i32, y, right, rd)
}
pub(super) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Option<Vec<f32>> {
let cell = cell_size.max(2.0);
let n = src.ch;
let b = ctx.bounds;
// Match the scalar averaging window, including floor/ceil at negative origins.
// Decline extreme geometry before its i32 arithmetic; the scalar fallback owns it.
let c0 = (
(((i64::from(out.x0) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_sub(2)?,
(((i64::from(out.y0) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_sub(2)?,
);
let c1 = (
(((i64::from(out.x1) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_add(2)?,
(((i64::from(out.y1) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_add(2)?,
);
let win = Rect::new(
b.x0.checked_add((c0.0 as f32 * cell).floor() as i32)?,
b.y0.checked_add((c0.1 as f32 * cell).floor() as i32)?,
b.x0.checked_add((c1.0.checked_add(1)? as f32 * cell).ceil() as i32)?,
b.y0.checked_add((c1.1.checked_add(1)? as f32 * cell).ceil() as i32)?,
)
.intersect(&src.rect)
.intersect(&b);
let mut grid = Grid::new(src, out.union(&win), ctx, cell, seed, false)?;
let origin = (b.x0 as f32, b.y0 as f32);
if grid.sites.iter().any(|s| !s.sampled) {
let mut acc = Vec::new();
let count = grid.sites.len().checked_mul(n.checked_add(1)?)?;
acc.try_reserve_exact(count).ok()?;
acc.resize(count, 0.0f32);
let columns = runs(win, origin.0, cell);
for y in win.y0..win.y1 {
let cy = key(y, origin.1, cell);
for &(cx, x0, x1) in &columns {
let neighbours = grid.neighbours(cx, cy)?;
// An interior cell's average is never used. Skip the entire run if
// all nine possible winners have an on-image colour sample.
if neighbours.iter().all(|&i| grid.sites.get(i).is_some_and(|s| s.sampled)) {
continue;
}
for x in x0..x1 {
let i = nearest(&grid, &neighbours, x, y)?;
if grid.sites.get(i)?.sampled {
continue;
}
let gx = grid.x0.checked_add((i % grid.width) as i32)?;
let gy = grid.y0.checked_add((i / grid.width) as i32)?;
if gx < c0.0 || gy < c0.1 || gx > c1.0 || gy > c1.1 {
continue;
}
let sum = acc.get_mut(i.checked_mul(n + 1)?..i.checked_add(1)?.checked_mul(n + 1)?)?;
let alpha = if ctx.alpha { src.get(x, y, n - 1) } else { 1.0 };
for (c, value) in sum.iter_mut().enumerate().take(n) {
let s = src.get(x, y, c);
*value += if ctx.alpha && c + 1 < n { s * alpha } else { s };
}
*sum.get_mut(n)? += 1.0;
}
}
}
for (site, sum) in grid.sites.iter_mut().zip(acc.chunks_exact(n + 1)) {
let count = *sum.get(n)?;
if !site.sampled && count > 0.0 {
for (dst, value) in site.color.iter_mut().zip(sum).take(n) {
*dst = *value / count;
}
crate::fxutil::unpremul_px(site.color.get_mut(..n)?, ctx.alpha);
site.sampled = true;
}
}
}
crystallize_grid(src, out, ctx, cell, &grid)
}
fn crystallize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, grid: &Grid) -> Option<Vec<f32>> {
let n = src.ch;
let b = ctx.bounds;
let origin = (b.x0 as f32, b.y0 as f32);
let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) };
let width = out.width() as usize;
let stride = width.checked_mul(n)?;
let columns = runs(out, origin.0, cell);
let mut distances = vec![f32::MAX; width];
let mut owners = vec![0u32; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
distances.fill(f32::MAX);
for &(cx, x0, x1) in &columns {
let neighbours = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
if cell >= 32.0 && out.width() <= 2048 {
certified_span::<false>(grid, &neighbours, x0, y, owners.get_mut(start..end)?, distances.get_mut(start..end)?)?;
continue;
}
owners.get_mut(start..end)?.fill(*neighbours.get(4)? as u32);
for &i in &neighbours {
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
for (offset, (distance, owner)) in distances.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (x0 + offset as i32) as f32 + 0.5;
let d = (site.point.0 - fx).powi(2) + dy2;
if d < *distance {
*distance = d;
*owner = i as u32;
}
}
}
}
for (x, (pixel, &owner)) in pixels.chunks_exact_mut(n).zip(&owners).enumerate() {
if !b.contains(out.x0 + x as i32, y) {
continue;
}
let site = grid.sites.get(owner as usize)?;
if site.sampled {
pixel.copy_from_slice(site.color.get(..n)?);
}
}
}
Some(result)
}
/// A site may only compete in its own base column and its two neighbours.
/// Derive the inclusive pixel runs from the exact forward f32 cell mapping.
fn influence_columns(out: Rect, origin: f32, cell: f32, grid: &Grid) -> Option<Vec<(i32, i32)>> {
let columns = runs(out, origin, cell);
(0..grid.width)
.map(|column| {
let gx = grid.x0.checked_add(i32::try_from(column).ok()?)?;
let lo = columns.partition_point(|&(cx, _, _)| cx < gx - 1);
let hi = columns.partition_point(|&(cx, _, _)| cx <= gx + 1);
if lo == hi {
return Some((out.x0, out.x0));
}
Some((columns.get(lo)?.1, columns.get(hi - 1)?.2))
})
.collect()
}
pub(super) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Option<Vec<f32>> {
let cell = cell_size.max(2.0);
// This path uses integer pixel centres and conservative one-pixel support
// margins. Beyond exact half-integer f32 coordinates use the scalar kernel.
if cell > 4096.0 || [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000) {
return None;
}
let grid = Grid::new(src, out, ctx, cell, seed, true)?;
pointillize_grid(src, out, ctx, cell, background, &grid)
}
fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f32; 4], grid: &Grid) -> Option<Vec<f32>> {
let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32);
let bg = native(ctx, background);
let n = src.ch;
let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) };
let columns = influence_columns(out, origin.0, cell, grid)?;
let base_runs = runs(out, origin.0, cell);
let width = out.width() as usize;
let stride = width.checked_mul(n)?;
let mut depths = vec![-0.5f32; width];
let mut owners = vec![u32::MAX; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
depths.fill(-0.5);
owners.fill(u32::MAX);
if cell >= 32.0 && out.width() <= 2048 {
for &(cx, x0, x1) in &base_runs {
let ids = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
certified_span::<true>(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?;
}
} else {
for gy in cy.checked_sub(1)?..=cy.checked_add(1)? {
let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?;
// Global gy/gx order is the original dy/dx tie order at every pixel.
for (column, &(x0, x1)) in columns.iter().enumerate() {
if x0 >= x1 {
continue;
}
let i = row.checked_add(column)?;
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
let reach = f64::from(site.radius) + 1.5;
let square = reach * reach - f64::from(dy2);
if square < 0.0 {
continue;
}
// The extra pixel covers subtraction/square/sqrt rounding for
// the bounded coordinates/radii above. The exact old depth
// comparison still decides every candidate that survives.
let reach_x = square.max(0.0).sqrt() + 1.0;
let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0);
let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1);
if lo >= hi {
continue;
}
let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?;
for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (lo + offset as i32) as f32 + 0.5;
let d = ((site.point.0 - fx).powi(2) + dy2).sqrt();
let candidate = site.radius - d;
if candidate > *depth {
*depth = candidate;
*owner = i as u32;
}
}
}
}
}
for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) {
let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 };
let mut canvas = bg;
if ctx.alpha {
*canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0));
}
if owner != u32::MAX {
let i = owner as usize;
let k = (depth + 0.5).clamp(0.0, 1.0);
let dot = grid.sites.get(i)?;
for (c, value) in canvas.iter_mut().enumerate().take(n) {
*value += (*dot.color.get(c)? - *value) * k;
}
}
pixel.copy_from_slice(canvas.get(..n)?);
}
}
Some(result)
}
/// The filters sample cell centres, not a dense neighbourhood. On ordinary
/// tiles read those sparse samples directly; keep only the original output
/// pixels for alpha and selection mixing. Crystallize boundary tiles still
/// use the dense path, whose row-order f32 averages are part of its result.
#[allow(clippy::too_many_arguments)]
pub(crate) fn tile(
surface: &photocraft_raster::Surface,
out: Rect,
ctx: &Ctx,
cell: f32,
seed: u32,
background: Option<[f32; 4]>,
extent: Option<Rect>,
) -> Option<(Image, Vec<f32>)> {
if !cell.is_finite()
|| !(2.0..=300.0).contains(&cell)
|| out.is_empty()
|| out.width() > 2048
|| out.height() > 2048
|| [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000)
|| extent.is_some_and(|e| e.is_empty() || out.intersect(&e) != out)
{
return None;
}
let dots = background.is_some();
let grid = Grid::new_with(out, ctx, surface.channels(), cell, seed, dots, |sx, sy, color| {
let (x, y) = match extent {
Some(e) => (sx.clamp(e.x0, e.x1 - 1), sy.clamp(e.y0, e.y1 - 1)),
None => (sx, sy),
};
surface.read_pixel(x, y, color);
ctx.bounds.contains(sx, sy)
})?;
if !dots && grid.sites.iter().any(|s| !s.sampled) {
return None;
}
let original = Image::read(surface, out);
let data = match background {
Some(bg) => pointillize_grid(&original, out, ctx, cell, bg, &grid)?,
None => crystallize_grid(&original, out, ctx, cell, &grid)?,
};
Some((original, data))
}
/// Same rounding and native byte order as Surface::write_region, before the
/// worker hands its result back. Bounds the allocation and retains float data
/// if an optional encode buffer cannot be reserved.
pub(crate) fn encode(format: photocraft_color::PixelFormat, samples: &[f32]) -> Option<Vec<u8>> {
use photocraft_color::SampleType;
let mut bytes = Vec::new();
let len = samples.len().checked_mul(format.sample.bytes())?;
bytes.try_reserve_exact(len).ok()?;
bytes.resize(len, 0);
match format.sample {
SampleType::U8 => {
for (dst, value) in bytes.iter_mut().zip(samples) {
*dst = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
SampleType::U16 => {
for (dst, value) in bytes.as_chunks_mut::<2>().0.iter_mut().zip(samples) {
dst.copy_from_slice(&((value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_ne_bytes());
}
}
SampleType::F32 => {
for (dst, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(samples) {
dst.copy_from_slice(&value.to_ne_bytes());
}
}
}
Some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
#[test]
fn span_certificates_keep_discrete_winners_and_antialias_coverage() {
for origin in [-16_777_210, -3_999_000, -37, 0, 3_999_000, 16_777_210] {
let out = Rect::new(origin, -3, origin + 601, 4);
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
let src = Image::new(out, 4);
for cell in [32.0, 33.25, 100.0, 300.0, 4096.0] {
for seed in 0..32 {
let grid = Grid::new(&src, out, &ctx, cell, seed, true).unwrap();
for y in out.y0..out.y1 {
let cy = key(y, ctx.bounds.y0 as f32, cell);
for (cx, x0, x1) in runs(out, ctx.bounds.x0 as f32, cell) {
let ids = grid.neighbours(cx, cy).unwrap();
for dots in [false, true] {
let mut owners = vec![u32::MAX; (x1 - x0) as usize];
let mut depths = vec![-0.5; owners.len()];
if dots {
certified_span::<true>(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap();
} else {
certified_span::<false>(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap();
}
for (offset, (&owner, &depth)) in owners.iter().zip(&depths).enumerate() {
let x = x0 + offset as i32;
let mut best = (if dots { -0.5 } else { -f32::MAX }, u32::MAX);
for &i in &ids {
let value = if dots { score::<true>(&grid.sites[i], x, y) } else { score::<false>(&grid.sites[i], x, y) };
if value > best.0 {
best = (value, i as u32);
}
}
assert_eq!(owner, best.1, "x={x} y={y} cell={cell} seed={seed}");
if dots && owner != u32::MAX {
assert_eq!((depth + 0.5).clamp(0.0, 1.0).to_bits(), (best.0 + 0.5).clamp(0.0, 1.0).to_bits());
}
}
}
}
}
}
}
}
}
#[test]
fn worker_encoding_is_surface_write_region_at_every_depth() {
let values = [f32::NAN, f32::NEG_INFINITY, -100.0, -0.0, f32::from_bits(1), 0.5 / 65535.0, 0.5 / 255.0, 0.5, 1.0, 1.1, f32::INFINITY];
let area = Rect::new(-17, -9, 281, 2);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let format = PixelFormat::new(mode, sample, alpha);
let data: Vec<_> = (0..area.width() as usize * area.height() as usize * format.channels()).map(|i| values[i % values.len()]).collect();
let mut surface = Surface::new(format);
surface.write_region(area, &data);
assert_eq!(encode(format, &data).unwrap(), surface.to_interleaved(area));
}
}
}
}
#[test]
fn optional_grid_rejects_unbounded_or_incomplete_scratch() {
let area = Rect::new(0, 0, 100_000, 100_000);
let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true };
let image = Image { rect: area, ch: 4, data: Vec::new() };
assert!(Grid::new(&image, area, &ctx, 2.0, 0, true).is_none());
assert!(Grid::new_with(area, &ctx, 4, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, 0, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, MAXC + 1, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, 4, f32::INFINITY, 0, true, |_, _, _| false).is_none());
}
}
+76
View File
@@ -191,3 +191,79 @@ fn facet_scratch_rejects_large_or_incomplete_storage() {
let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() };
assert!(facet::run(&src, out, &ctx).is_none());
}
#[test]
fn cell_filters_match_scalar_models_edges_and_fractional_cells() {
let bounds = Rect::new(-19, -13, 32, 29);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Multichannel] {
for alpha in [false, true] {
let ctx = Ctx { bounds, mode, alpha };
for pattern in 0..5 {
for halo in [0, 7] {
let src = fixture(bounds.inflate(halo), &ctx, pattern);
for out in [bounds, Rect::new(-23, -15, 37, 31), Rect::new(-7, 2, 18, 13)] {
for cell in [2.0, 3.75, 10.0, 100.0, 300.0] {
for seed in [0, 37, u32::MAX] {
let got = cells::crystallize(&src, out, &ctx, cell, seed).unwrap();
assert_bits(&got, &crystallize_direct(&src, out, &ctx, cell, seed));
for bg in [[1.0; 4], [0.1, 0.8, 0.4, 0.25]] {
let got = cells::pointillize(&src, out, &ctx, cell, seed, bg).unwrap();
assert_bits(&got, &pointillize_direct(&src, out, &ctx, cell, seed, bg));
}
}
}
}
}
}
}
}
}
#[test]
fn cell_filters_tiled_storage_matches_scalar_with_soft_selection() {
use crate::{FilterParams, apply_tiled};
let bounds = Rect::new(-17, -9, 86, 78);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let ctx = Ctx { bounds, mode, alpha };
let src = fixture(bounds, &ctx, 2);
let mut surface = Surface::new(PixelFormat::new(mode, depth, alpha));
surface.write_region(bounds, &src.data);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false));
let coverage: Vec<_> = (0..bounds.width() * bounds.height()).map(|i| (i % 7) as f32 / 6.0).collect();
selection.write_region(bounds, &coverage);
for params in [
FilterParams::Crystallize { cell_size: 3.75, seed: 37 },
FilterParams::Crystallize { cell_size: 10.0, seed: 37 },
FilterParams::Pointillize { cell_size: 3.75, seed: 37, background: [1.0; 4] },
FilterParams::Pointillize { cell_size: 10.0, seed: 37, background: [0.1, 0.8, 0.4, 0.25] },
] {
for extent in [None, Some(bounds)] {
assert!(matches!(params.halo(), crate::Halo::Radius(_)));
let halo = match params.halo() {
crate::Halo::Radius(r) => r,
_ => 0,
};
let image = match extent {
Some(e) => Image::read_clamped(&surface, bounds.inflate(halo), e),
None => Image::read(&surface, bounds.inflate(halo)),
};
let mut expected_data = match params {
FilterParams::Crystallize { cell_size, seed } => crystallize_direct(&image, bounds, &ctx, cell_size, seed),
FilterParams::Pointillize { cell_size, seed, background } => pointillize_direct(&image, bounds, &ctx, cell_size, seed, background),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
let got = apply_tiled(&surface, &params, bounds, bounds, Some(&selection), tile, extent);
assert_bits(&got.read_region(bounds), &expected.read_region(bounds));
}
}
}
}
}
}
}
+61 -12
View File
@@ -159,8 +159,37 @@ fn cover(o: i32, scale: f64) -> (i32, i32) {
(a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32)
}
/// Unclamped weights of output sample `o` over source samples `lo..lo + len`.
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec<f64>) {
/// The source samples worth a weight each: the edge, or the content with one sample of slack on
/// each side to carry the weight of everything beyond it (read as the default pixel).
fn keep_range(edge: Option<(i32, i32)>, content: (i32, i32)) -> (i64, i64) {
let (a, b) = edge.unwrap_or((content.0.saturating_sub(1), content.1.saturating_add(1)));
(i64::from(a), i64::from(b.max(a)))
}
/// Adds `v` spread evenly over the source samples `[a, b)` to `w`, which holds the samples
/// from `lo`: samples beyond either end of it add to that end slot instead of needing a slot.
fn spread(w: &mut [f64], lo: i64, a: i64, b: i64, v: f64) {
let Some(last) = w.len().checked_sub(1) else { return };
let each = v / (b - a).max(1) as f64;
let end = lo + w.len() as i64;
let (ia, ib) = (a.max(lo), b.min(end));
if ia < ib {
for slot in w.get_mut((ia - lo) as usize..(ib - lo) as usize).into_iter().flatten() {
*slot += each;
}
}
if a < lo {
w[0] += each * (b.min(lo) - a) as f64;
}
if b > end {
w[last] += each * (b - a.max(end)) as f64;
}
}
/// Weights of output sample `o` over source samples `lo..lo + len`, unclamped except that
/// reductions fold what lies beyond the edge (or the `content`) onto its ends, so a tiny scale
/// doesn't cost a weight per source sample it covers.
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> (i32, Vec<f64>) {
match kernel {
Kernel::Nearest => {
let u = (o as f64 + 0.5) / scale - 0.5;
@@ -168,7 +197,11 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32
}
Kernel::Bilinear if scale < 0.5 => {
let (a, b) = cover(o, scale);
(a, vec![1.0 / (b - a) as f64; (b - a) as usize])
let (ka, kb) = keep_range(edge, content);
let (lo, hi) = (i64::from(a).clamp(ka, kb), (i64::from(b) - 1).clamp(ka, kb));
let mut w = vec![0.0; (hi - lo + 1) as usize];
spread(&mut w, lo, i64::from(a), i64::from(b), 1.0);
(lo as i32, w)
}
Kernel::Bilinear => {
let u = position(o, scale, edge);
@@ -179,18 +212,18 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32
// Averaged down to four times the target first (pixels of `inner`), then filtered.
let inner = 4.0 * scale;
let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1));
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge);
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge, (i32::MIN / 4, i32::MAX / 4));
let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a)));
let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect();
let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0);
let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo);
let mut w = vec![0.0; (hi - lo).max(0) as usize];
let (ka, kb) = keep_range(edge, content);
let lo = i64::from(boxes.iter().map(|b| b.0).min().unwrap_or(0));
let hi = i64::from(boxes.iter().map(|b| b.1).max().unwrap_or(lo as i32));
let (klo, khi) = (lo.clamp(ka, kb), (hi - 1).clamp(ka, kb));
let mut w = vec![0.0; (khi - klo + 1) as usize];
for ((a, b), v) in boxes.into_iter().zip(kw) {
for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() {
*slot += v / (b - a) as f64;
}
spread(&mut w, klo, i64::from(a), i64::from(b), v);
}
(lo, w)
(klo as i32, w)
}
Kernel::Keys { a, stretch } => {
let u = position(o, scale, edge);
@@ -225,7 +258,7 @@ fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32
let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel };
(dst0..dst1)
.map(|o| {
let (lo, w) = weights(o, scale, kernel, edge);
let (lo, w) = weights(o, scale, kernel, edge, content);
let hi = lo + w.len() as i32 - 1;
// Fold the weights of taps beyond the edge onto the edge sample.
let start = clamp(lo);
@@ -724,6 +757,22 @@ mod tests {
assert!((p[0] - 0.5).abs() < 0.05, "{p:?}");
}
/// A reduction by a tiny scale covers millions of source samples per output sample; only the
/// ones in the content get a weight, the rest fold onto its ends or into `outside`.
#[test]
fn tiny_scales_do_not_get_a_weight_per_covered_sample() {
for kernel in [Kernel::Bilinear, Kernel::Keys { a: -0.5, stretch: 1.0 }] {
for edge in [None, Some((0, 99))] {
let t = taps(0, 3, 1e-6, kernel, edge, (0, 99));
for t in &t {
assert!(t.weights.len() <= 100, "{}", t.weights.len());
let total = t.weights.iter().sum::<f32>() + t.outside;
assert!((total - 1.0).abs() < 1e-3, "{total}");
}
}
}
}
#[test]
fn transparent_edges_do_not_darken() {
let mut s = Surface::new(PixelFormat::RGBA32F);
+63 -11
View File
@@ -359,19 +359,18 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect:
});
let seam_count = w - new_w;
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true);
let mut removed = Vec::with_capacity(seam_count);
// Removing a seam shifts the panel left, so a panel column is not an original one. Tracking
// the original column per pixel costs a third buffer that every seam has to shift (24 MB a
// seam at 24 MP); counting the columns already taken from that row is exact and costs a
// binary search per removed column.
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, false);
let mut removed_by_row: Vec<Vec<usize>> = vec![Vec::new(); proxy_h];
let mut taken = 0usize;
while carver.cw > new_w {
ctl.check()?;
removed.push(carver.remove_seam());
ctl.progress(removed.len() as f32 / seam_count as f32);
}
let mut removed_by_row = Vec::with_capacity(proxy_h);
for proxy_y in 0..proxy_h {
let mut columns: Vec<usize> = removed.iter().map(|path| path[proxy_y]).collect();
columns.sort_unstable();
removed_by_row.push(columns);
record_removed(&mut removed_by_row, &carver.remove_seam());
taken += 1;
ctl.progress(taken as f32 / seam_count as f32);
}
let mut out = Vec::with_capacity(new_w * h * ch);
let mut columns = vec![0; seam_count];
@@ -403,6 +402,32 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect:
Ok(out)
}
/// Add one seam, given in panel columns, to each row's sorted list of removed original columns.
/// For the `i`-th column already taken from a row, `o - i` is the panel column it occupied, and
/// that never decreases along the list, so the taken columns at or left of panel column `x` are a
/// prefix found by binary search. `x` plus that prefix's length is the original column, and every
/// removal the search passed is left of it, so it inserts where the search stopped. The rows are
/// independent, so they update in parallel like the pixel shift.
fn record_removed(removed_by_row: &mut [Vec<usize>], seam: &[usize]) {
// The entries are the original columns still taken from one row, sorted, so entry `i` is at
// least `i` and `o - i` cannot underflow.
let add = |(row, &x): (&mut Vec<usize>, &usize)| {
let (mut lo, mut hi) = (0, row.len());
while lo < hi {
let mid = lo + (hi - lo) / 2;
if row[mid] - mid <= x { lo = mid + 1 } else { hi = mid }
}
row.insert(lo, x + lo);
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
removed_by_row.par_iter_mut().zip(seam.par_iter()).for_each(add);
}
#[cfg(target_arch = "wasm32")]
removed_by_row.iter_mut().zip(seam.iter()).for_each(add);
}
/// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing).
pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
// Never cancelled; the fallback is unreachable.
@@ -761,6 +786,33 @@ mod tests {
assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled));
}
/// The proxy path counts each removed original column instead of shifting an index map, so a
/// tracking and a counting carver must agree: run both side by side down to one column and
/// check every row's list after every seam (tie-heavy noise gives rows different histories;
/// 1-4 channels, with and without protection).
#[test]
fn counted_removed_columns_match_the_tracked_map() {
let cases: [(usize, usize, usize, u32, bool); 5] =
[(37, 9, 1, 2, false), (40, 12, 2, 3, true), (53, 7, 3, 255, false), (29, 16, 4, 1000, true), (64, 5, 3, 2, true)];
for (i, &(w, h, ch, levels, prot)) in cases.iter().enumerate() {
let img = noise(w * h * ch, levels, 31 + i as u32);
let protect = prot.then(|| noise(w * h, 3, 77 + i as u32));
let mut tracked = Carver::new(w, h, ch, img.clone(), protect.clone(), true);
let mut counted = Carver::new(w, h, ch, img, protect, false);
let mut expected: Vec<Vec<usize>> = vec![Vec::new(); h];
let mut got: Vec<Vec<usize>> = vec![Vec::new(); h];
while tracked.cw > 1 {
let seam = tracked.remove_seam();
for (row, &x) in expected.iter_mut().zip(seam.iter()) {
let at = row.partition_point(|&o| o < x);
row.insert(at, x);
}
record_removed(&mut got, &counted.remove_seam());
assert_eq!(got, expected, "case {i} after {} seams", w - tracked.cw);
}
}
}
#[test]
fn enlarging_duplicates_flat_areas() {
let (w, h) = (30, 8);
+37 -15
View File
@@ -524,23 +524,24 @@ fn components4(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
/// rule as the magic wand's anti-aliasing).
pub fn antialias_u8(mask: &mut [u8], w: usize, h: usize) {
let src = mask.to_vec();
let at = |x: i32, y: i32, v: u8| if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 { v } else { src[y as usize * w + x as usize] };
// A span whose 3×3 neighbourhood holds one value has no edge pixel, so it is only compared:
// most of a full-resolution mask is far from its boundary. 64 pixels keep the three row
// compares cheap per pixel, and a span that does reach the boundary costs few extra pixels.
const SPAN: usize = 64;
let uniform = |y: usize, x0: usize, x1: usize| {
let (a, b, v) = (x0.saturating_sub(1), (x1 + 1).min(w), src[y * w + x0]);
(y.saturating_sub(1)..(y + 2).min(h)).all(|ny| src[ny * w + a..ny * w + b].iter().all(|n| *n == v))
};
let row = |y: usize, out: &mut [u8]| {
for (x, o) in out.iter_mut().enumerate() {
let v = src[y * w + x];
let mut sum = 0u32;
let mut edge = false;
for dy in -1..=1 {
for dx in -1..=1 {
let n = at(x as i32 + dx, y as i32 + dy, v);
edge |= n != v;
sum += n as u32;
}
for (c, span) in out.chunks_mut(SPAN).enumerate() {
let x0 = c * SPAN;
if uniform(y, x0, x0 + span.len()) {
continue;
}
if edge {
let avg = sum as f32 / (9.0 * 255.0);
let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg };
*o = (f * 255.0).round() as u8;
for (i, o) in span.iter_mut().enumerate() {
if let Some(v) = edge_value(&src, w, h, x0 + i, y) {
*o = v;
}
}
}
};
@@ -553,6 +554,27 @@ pub fn antialias_u8(mask: &mut [u8], w: usize, h: usize) {
mask.chunks_mut(w).enumerate().for_each(|(y, r)| row(y, r));
}
/// The anti-aliased value of pixel `(x, y)` of `src` (`w` × `h`), or `None` when every neighbour
/// in its 3×3 neighbourhood has its value (neighbours outside the mask count as the pixel itself).
fn edge_value(src: &[u8], w: usize, h: usize, x: usize, y: usize) -> Option<u8> {
let v = src[y * w + x];
let mut sum = 0u32;
let mut edge = false;
for dy in -1..=1 {
for dx in -1..=1 {
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
let n = if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { v } else { src[ny as usize * w + nx as usize] };
edge |= n != v;
sum += n as u32;
}
}
edge.then(|| {
let avg = sum as f32 / (9.0 * 255.0);
let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg };
(f * 255.0).round() as u8
})
}
/// Shrinks a region's box to its non-zero pixels (`None` if empty).
pub fn trim_region(r: Region) -> Option<Region> {
let w = r.bbox.width() as usize;
+44
View File
@@ -903,6 +903,50 @@ fn select_subject_leaves_a_background_coloured_gap_open() {
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn antialias_blends_edge_pixels_with_their_neighbourhood() {
// A lone selected pixel keeps most of its coverage and its neighbours get half of a ninth;
// neighbours outside the mask count as the pixel itself.
let mut m = vec![0, 0, 0, 0, 255, 0, 0, 0, 0];
antialias_u8(&mut m, 3, 3);
assert_eq!(m, [14, 14, 14, 14, 142, 14, 14, 14, 14]);
}
#[test]
fn antialias_skips_only_spans_without_edge_pixels() {
// Every pixel through the per-pixel rule, no span skipped.
let every_pixel = |mask: &mut [u8], w: usize, h: usize| {
let src = mask.to_vec();
for y in 0..h {
for x in 0..w {
if let Some(v) = edge_value(&src, w, h, x, y) {
mask[y * w + x] = v;
}
}
}
};
let mut rng = Rng::new(11);
// Widths around the 64-pixel span, single rows and columns, discs, noise and stripes.
for (w, h) in [(1usize, 1usize), (1, 9), (9, 1), (63, 4), (64, 3), (65, 5), (127, 2), (129, 70), (300, 200)] {
let (cx, cy, r) = (w as f32 * 0.6, h as f32 * 0.4, w.min(h) as f32 / 3.0);
let masks: Vec<Vec<u8>> = vec![
vec![255; w * h],
vec![0; w * h],
(0..w * h).map(|i| if ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < r { 255 } else { 0 }).collect(),
(0..w * h).map(|_| if rng.f32() < 0.02 { 255 } else { 0 }).collect(),
(0..w * h).map(|_| if rng.f32() < 0.5 { 255 } else { 0 }).collect(),
(0..w * h).map(|i| if (i % w) / 64 % 2 == 0 { 255 } else { 0 }).collect(),
(0..w * h).map(|i| if i % w == w - 1 || i / w == h / 2 { 255 } else { 0 }).collect(),
];
for (k, m) in masks.into_iter().enumerate() {
let (mut fast, mut slow) = (m.clone(), m);
antialias_u8(&mut fast, w, h);
every_pixel(&mut slow, w, h);
assert!(fast == slow, "{w}×{h} mask {k}");
}
}
}
/// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`).
#[test]
#[ignore = "timing only"]
+4 -1
View File
@@ -761,10 +761,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
mod distance;
mod lasso;
mod marquee;
pub use distance::edt;
pub use marquee::{combine_surfaces, ellipse_surface};
pub(crate) use lasso::clip_segment;
pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface};
pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface};
/// Grows the selection by `r` pixels.
pub fn expand(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
+291
View File
@@ -0,0 +1,291 @@
//! Lasso shapes built straight into tiles (#8783): only the tiles an edge crosses are rasterised,
//! and every tile wholly inside the polygon shares one solid tile, so a thin lasso across a huge
//! canvas costs the tiles along its edges, not its bounding box. The bytes are those
//! [`super::polygon`] gives over the canvas.
use std::collections::BTreeSet;
use std::sync::Arc;
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
use crate::photo_util::par_map;
const TS: usize = TILE_SIZE as usize;
/// Most partly covered tiles one painted shape (a lasso, a brush stroke) may hold: 2²⁸ pixels'
/// worth, 256 MB as GRAY8. A thin shape touches a whole tile wherever it passes, so this, not
/// its area, bounds what painting it allocates.
pub const MAX_PAINTED_TILES: usize = (1 << 28) / (TS * TS);
/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or `None` when it misses it.
pub(crate) fn clip_segment(a: (f32, f32), b: (f32, f32), r: Rect) -> Option<((f32, f32), (f32, f32))> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f32, 1.0f32);
for (p, q) in [(-dx, a.0 - r.x0 as f32), (dx, r.x1 as f32 - a.0), (-dy, a.1 - r.y0 as f32), (dy, r.y1 as f32 - a.1)] {
if p == 0.0 {
if q < 0.0 {
return None;
}
} else {
let t = q / p;
if p < 0.0 {
t0 = t0.max(t);
} else {
t1 = t1.min(t);
}
}
}
(t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1)))
}
/// The pixels the polygon through `points` can touch within `clip` (its bounding box, grown by a
/// pixel, as the Lasso has always rasterised it).
fn polygon_area(points: &[(f32, f32)], clip: Rect) -> Rect {
let (mut x0, mut y0, mut x1, mut y1) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
for p in points {
(x0, y0, x1, y1) = (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1));
}
if points.len() < 3 || !(x0 <= x1 && y0 <= y1) {
return Rect::EMPTY;
}
let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32;
Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&clip)
}
/// The tiles of `clip` the edges of the polygon through `points` cross (with a pixel's margin),
/// in order: the only ones whose pixels can differ. `None` when there are more than `limit`.
pub fn polygon_edge_tiles(points: &[(f32, f32)], clip: Rect, limit: usize) -> Option<BTreeSet<TileCoord>> {
let area = polygon_area(points, clip);
let mut set = BTreeSet::new();
if area.is_empty() {
return Some(set);
}
let step = TILE_SIZE as f32 / 4.0;
for (&a, &b) in points.iter().zip(points.iter().cycle().skip(1)) {
let Some((a, b)) = clip_segment(a, b, area.inflate(2)) else { continue };
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / step).ceil().clamp(1.0, 1e6) as usize;
let at = |k: usize| {
let t = k as f32 / pieces as f32;
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
};
for k in 0..pieces {
let (p, q) = (at(k), at(k + 1));
let lo = |u: f32, v: f32| u.min(v).floor() as i32 - 1;
let hi = |u: f32, v: f32| u.max(v).floor() as i32 + 2;
for tc in Rect::new(lo(p.0, q.0), lo(p.1, q.1), hi(p.0, q.0), hi(p.1, q.1)).intersect(&area).tiles() {
set.insert(tc);
if set.len() > limit {
return None;
}
}
}
}
Some(set)
}
/// One polygon edge, as [`super::polygon`] keeps it.
struct Edge {
top: f32,
bottom: f32,
a: (f32, f32),
b: (f32, f32),
}
/// The anti-aliased (even-odd) polygon through `points`, cut to `clip`, as a GRAY8 selection: the
/// bytes of [`super::polygon`] over `clip`, quantised. Edge tiles are rasterised, tiles wholly
/// inside share one solid tile. `None` when the edges cross more than [`MAX_PAINTED_TILES`] tiles.
pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option<Surface> {
let mut s = Surface::new(PixelFormat::GRAY8);
let area = polygon_area(points, clip);
if area.is_empty() {
return Some(s);
}
let edge_tiles = polygon_edge_tiles(points, clip, MAX_PAINTED_TILES)?;
const SUB: usize = 4;
// Sample rows as `polygon` over `clip` takes them, so the bytes match.
let sample_y = |y: i32, k: usize| clip.y0 as f32 + (y - clip.y0) as f32 + (k as f32 + 0.5) / SUB as f32;
let mut edges: Vec<Edge> = points
.iter()
.zip(points.iter().cycle().skip(1))
.filter_map(|(&a, &b)| {
if a.1 < b.1 {
Some(Edge { top: a.1, bottom: b.1, a, b })
} else if b.1 < a.1 {
Some(Edge { top: b.1, bottom: a.1, a, b })
} else {
None
}
})
.collect();
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
let crossings = |e: &[&Edge], y: f32, xs: &mut Vec<f32>| {
xs.clear();
xs.extend(e.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
xs.sort_by(|a, b| a.total_cmp(b));
};
let full = s.solid_tile(&[1.0]);
let blank = s.solid_tile(&[0.0]);
let bands: Vec<i32> = Rect::new(area.x0, area.y0, area.x0 + 1, area.y1).tiles().map(|tc| tc.ty).collect();
let built = par_map(bands.len(), |bi| -> Vec<(TileCoord, Arc<Tile>)> {
let Some(&ty) = bands.get(bi) else { return Vec::new() };
let rows = Rect::new(area.x0, ty * TILE_SIZE, area.x1, (ty + 1) * TILE_SIZE).intersect(&area);
// In `x` order (`TileCoord` orders by `tx` first).
let cols: Vec<TileCoord> = edge_tiles.iter().filter(|tc| tc.ty == ty).copied().collect();
let mut out = Vec::new();
let (first, last) = (sample_y(rows.y0, 0), sample_y(rows.y1 - 1, SUB - 1));
let reach = edges.partition_point(|e| e.top <= last);
let band_edges = edges.get(..reach).unwrap_or_default();
let mut xs: Vec<f32> = Vec::new();
// Tiles no edge crosses are wholly in or out: one sample row decides them all.
let y_mid = sample_y(rows.y0 + rows.height() as i32 / 2, 0);
let mid: Vec<&Edge> = band_edges.iter().filter(|e| e.top <= y_mid && e.bottom > y_mid).collect();
crossings(&mid, y_mid, &mut xs);
for tc in rows.tiles() {
if cols.binary_search(&tc).is_ok() {
continue;
}
let tr = tc.rect().intersect(&rows);
let cx = (tr.x0 + tr.x1) as f32 / 2.0;
if xs.partition_point(|x| *x <= cx) % 2 == 0 {
continue;
}
if tr == tc.rect() {
out.push((tc, Arc::clone(&full)));
} else {
let mut tile = Tile::clone(&blank);
fill(&mut tile, tc, tr, u8::MAX);
out.push((tc, Arc::new(tile)));
}
}
if cols.is_empty() {
return out;
}
// The edge tiles: `polygon`'s scanline, accumulated only over their columns.
let x_of = |tc: &TileCoord| tc.rect().intersect(&rows);
let spans: Vec<Rect> = cols.iter().map(x_of).collect();
let mut acc = vec![0.0f32; cols.len() * TS];
let mut tiles: Vec<Tile> = cols.iter().map(|_| Tile::clone(&blank)).collect();
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
let mut active: Vec<&Edge> = Vec::new();
let origin = clip.x0 as f32;
for y in rows.y0..rows.y1 {
acc.fill(0.0);
for k in 0..SUB {
let sy = sample_y(y, k);
while let Some(e) = pending.next_if(|e| e.top <= sy) {
active.push(e);
}
active.retain(|e| e.bottom > sy);
if active.is_empty() {
continue;
}
crossings(&active, sy, &mut xs);
for pair in xs.as_chunks::<2>().0 {
let (x0, x1) = ((pair[0] - origin).max(0.0), (pair[1] - origin).min(clip.width() as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (clip.x0 + x0.floor() as i32, clip.x0 + x1.ceil() as i32);
let start = spans.partition_point(|r| r.x1 <= c0);
for (ci, r) in spans.iter().enumerate().skip(start).take_while(|(_, r)| r.x0 < c1) {
for x in c0.max(r.x0)..c1.min(r.x1) {
let cx = (x - clip.x0) as f32;
let cov = (cx + 1.0).min(x1) - cx.max(x0);
if let Some(v) = acc.get_mut(ci * TS + (x - r.x0) as usize) {
*v += cov.max(0.0) / SUB as f32;
}
}
}
}
}
for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::<TS>().0) {
let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize;
let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue };
for (d, v) in dst.iter_mut().zip(row) {
*d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
}
for (tc, tile) in cols.into_iter().zip(tiles) {
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
if hi > 0 {
out.push((tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) }));
}
}
out
});
s.put_tiles(built.into_iter().flatten());
Some(s)
}
/// Sets `tr` (within tile `tc`) to `v`.
fn fill(tile: &mut Tile, tc: TileCoord, tr: Rect, v: u8) {
let o = tc.rect();
for y in tr.y0..tr.y1 {
let base = (y - o.y0) as usize * TS;
if let Some(row) = tile.bytes_mut().get_mut(base + (tr.x0 - o.x0) as usize..base + (tr.x1 - o.x0) as usize) {
row.fill(v);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The dense rasteriser over `clip`, quantised as the Lasso stores it.
fn dense(points: &[(f32, f32)], clip: Rect) -> Surface {
let m: Vec<u8> = super::super::polygon(points, clip, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect();
let mut s = Surface::from_interleaved(PixelFormat::GRAY8, clip, &m);
s.prune();
s
}
#[test]
fn matches_the_dense_polygon() {
let clip = Rect::new(-40, 10, 1300, 900);
let shapes: [&[(f32, f32)]; 6] = [
&[(10.5, 20.25), (1200.0, 40.0), (600.0, 880.7)],
// Whole tiles inside, edges exactly on tile boundaries.
&[(0.0, 256.0), (768.0, 256.0), (768.0, 768.0), (0.0, 768.0)],
// Self-intersecting (even-odd), past the clip.
&[(-300.0, -50.0), (1500.0, 950.0), (1500.0, -50.0), (-300.0, 950.0)],
&[(100.0, 100.0), (101.0, 100.0), (100.5, 700.0)],
&[(5.0, 15.0), (5.0, 15.0), (5.0, 15.0)],
&[(2000.0, 2000.0), (2100.0, 2000.0), (2000.0, 2100.0)],
];
for pts in shapes {
let s = polygon_surface(pts, clip).expect("within the tile limit");
let d = dense(pts, clip);
let area = clip.union(&s.tile_bounds());
assert!(s.read_region(area) == d.read_region(area), "{pts:?}");
}
}
/// A thin sliver across a 100,000 px canvas: only the tiles along it are built (the dense
/// rasteriser allocated its 10-billion-pixel bounding box; the #8783 review).
#[test]
fn a_thin_sliver_across_a_huge_canvas_costs_its_edge_tiles() {
let clip = Rect::new(0, 0, 100_000, 100_000);
let sliver = [(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)];
let tiles = polygon_edge_tiles(&sliver, clip, MAX_PAINTED_TILES).expect("within the limit");
assert!(tiles.len() < 2000, "{}", tiles.len());
let s = polygon_surface(&sliver, clip).expect("within the limit");
assert!(s.tile_count() <= tiles.len());
assert!(s.pixel(50_000, 50_000)[0] > 0.0 && s.pixel(50_000, 40_000)[0] == 0.0);
// A zigzag that crosses more tiles than the limit is refused.
let zigzag: Vec<(f32, f32)> = (0..200).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 500.0)).collect();
assert!(polygon_edge_tiles(&zigzag, clip, MAX_PAINTED_TILES).is_none());
assert!(polygon_surface(&zigzag, clip).is_none());
}
#[test]
fn a_huge_polygon_shares_one_tile_inside() {
let clip = Rect::new(0, 0, 51_200, 51_200);
let s = polygon_surface(&[(-10.0, -10.0), (60_000.0, -10.0), (60_000.0, 60_000.0), (-10.0, 60_000.0)], clip).expect("no edge tiles");
let shared = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect::<BTreeSet<_>>();
assert!(s.tile_count() == 200 * 200 && shared.len() == 1, "{} tiles, {} distinct", s.tile_count(), shared.len());
}
}
+80
View File
@@ -236,6 +236,60 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3
out
}
/// [`combine_surfaces`] for an `f` that keeps `a` wherever `b` reads 0 (Add, Subtract), visiting
/// only `b`'s tiles within `area`: the rest of `a` (its other tiles and its untouched value) is
/// kept as it is, so painting a thin stroke across a huge canvas costs the tiles it paints, not
/// the canvas (#8783). Falls back to [`combine_surfaces`] over `area` for other inputs.
pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface {
let gray = PixelFormat::GRAY8;
let lut: Vec<u8> = (0..=u16::MAX).map(|i| encode8(f(decode8((i >> 8) as u8), decode8(i as u8)))).collect();
let pair = |x: u8, y: u8| lut.get(usize::from(x) << 8 | usize::from(y)).copied().unwrap_or(0);
if a.format() != gray || b.format() != gray || b.default_bytes() != [0] || !(0..=255u8).all(|v| pair(v, 0) == v) {
return combine_surfaces(a, b, area, f);
}
let da = a.default_bytes().first().copied().unwrap_or(0);
let mut out = a.clone();
let (full, untouched) = (out.solid_tile(&[1.0]), out.solid_tile(&[decode8(da)]));
let coords: Vec<TileCoord> = b.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&area).is_empty()).collect();
// Each tile: `Some(tile)` to store, `None` when it reads as `a`'s untouched value.
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let tr = rect.intersect(&area);
let tb = b.tile(tc)?;
let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched));
let data = tile.bytes_mut();
let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize);
for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize {
let (s, e) = (ly * TS + x0, ly * TS + x1);
let (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue };
for (d, y) in dst.iter_mut().zip(src) {
*d = pair(*d, *y);
}
}
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
Some((
tc,
if lo == da && hi == da {
None
} else if lo == u8::MAX {
Some(Arc::clone(&full))
} else {
Some(Arc::new(tile))
},
))
});
for (tc, tile) in built.into_iter().flatten() {
match tile {
Some(t) => out.put_tiles([(tc, t)]),
// `take_tiles` only allocates for a tile that is missing; this one is there.
None if out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())),
None => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -311,4 +365,30 @@ mod tests {
}
}
}
/// Add and Subtract through only the new shape's tiles give the bytes of the whole-area
/// combination, keep the old selection's other tiles shared, and keep an untouched value
/// (a layer mask's reveal-all) outside them.
#[test]
fn sparse_combination_matches_the_whole_area_one() {
let area = Rect::new(0, 0, 1100, 700);
let mut rect = Surface::new(PixelFormat::GRAY8);
rect.fill_rect(Rect::from_xywh(200, 100, 600, 520), &[1.0]);
let ell = old_ellipse(Rect::from_xywh(-60, 250, 700, 500), area, true);
let far = ellipse_surface(Rect::from_xywh(900, 20, 150, 150), area, true);
let ops: [fn(f32, f32) -> f32; 2] = [|a, b| a.max(b), |a, b| (a - b).max(0.0)];
for (a, b) in [(&rect, &ell), (&ell, &rect), (&ell, &ell), (&rect, &far)] {
for f in ops {
let sparse = combine_sparse(a, b, area, f);
assert!(sparse.read_region(area) == old_combine(a, b, area, f).read_region(area));
assert!(sparse.tiles().all(|(_, t)| t.bytes().iter().any(|v| *v != 0)), "no empty tiles kept");
}
}
let kept = combine_sparse(&rect, &far, area, |a, b| a.max(b));
let tc = TileCoord::new(1, 1);
assert!(Arc::ptr_eq(kept.tile(tc).expect("kept"), rect.tile(tc).expect("there")), "untouched tiles stay shared");
let reveal = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
let cut = combine_sparse(&reveal, &far, area, |a, b| (a - b).max(0.0));
assert!(cut.pixel(5000, 5000)[0] == 1.0 && cut.pixel(975, 95)[0] == 0.0);
}
}
+12 -1
View File
@@ -29,14 +29,25 @@ const MODELS: [ColorMode; 8] =
#[test]
fn swept_blocks_match_the_pixel_search() {
let bounds = Rect::new(-13, -9, 31, 26);
// Unless the heights are level-based, the colour mode reaches extrude only through the channel
// count and whether the samples are CMYK ink, so modes that agree on both repeat those cases.
// A run takes a quarter of the rest, a different quarter each day.
let mut seen = std::collections::HashSet::new();
let sweep = photocraft_testkit::Sweep::new(4);
let mut case = 0;
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
let repeat = !seen.insert((n, alpha, mode == ColorMode::Cmyk));
for kind in 0..5 {
let src = image(bounds.inflate(20), n, kind);
for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] {
for flags in 0..8 {
for flags in (0..8).filter(|f| !repeat || f & 1 != 0) {
case += 1;
if !sweep.includes(case) {
continue;
}
let spec = ExtrudeSpec {
kind: ExtrudeType::Blocks,
size,
+9
View File
@@ -640,3 +640,12 @@ fn mix_selection_reads_the_selection_once_per_tile() {
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
}
}
#[test]
fn blur_halos_follow_the_clamped_radius_and_never_overflow() {
let smart = |radius| FilterParams::SmartBlur { radius, threshold: 25.0, quality: BlurQuality::default(), mode: SmartBlurMode::default() };
assert_eq!(smart(1e9).halo(), Halo::Radius(101));
assert_eq!(smart(f32::INFINITY).halo(), Halo::Radius(101));
let gaussian = FilterParams::GaussianBlur { radius: f32::INFINITY };
assert_eq!(gaussian.halo(), Halo::Radius(i32::MAX));
}
+16
View File
@@ -52,11 +52,19 @@ fn tilt_shift_matches_frozen_kernel_at_band_boundaries_and_edges() {
}
cases.push(params(80.0, (0.5, 0.5), 0.0, 5.0, 0.1));
cases.push(params(80.0, (-5.0, -5.0), 27.0, 0.1, 0.15));
// The channel count and alpha are independent of the band geometry: a run takes a quarter of
// their combinations, a different quarter each day.
let sweep = photocraft_testkit::Sweep::new(4);
let mut case = 0;
for ch in 1..=6 {
let src = image(bounds.inflate(12), ch);
for alpha in [false, true] {
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha };
for p in &cases {
case += 1;
if !sweep.includes(case) {
continue;
}
check(&src, bounds, &ctx, p);
check(&src, Rect::new(-7, 17, 15, 39), &ctx, p);
}
@@ -146,6 +154,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
selection.fill_rect(bounds, &[0.4]);
selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]);
selection.fill_rect(Rect::new(10, 20, 20, 30), &[0.0]);
// Pixel formats and tile layouts are independent: a run takes a quarter of their combinations,
// a different quarter each day.
let sweep = photocraft_testkit::Sweep::new(4);
let mut case = 0;
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
@@ -167,6 +179,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
assert_eq!(p.halo_for(bounds), crate::Halo::Radius(radius));
for extent in [None, Some(bounds)] {
for tile in [16, 47, 128] {
case += 1;
if !sweep.includes(case) {
continue;
}
let got = crate::apply_tiled(&surface, &p, area, bounds, Some(&selection), tile, extent);
let mut want = surface.clone();
for y in (area.y0..area.y1).step_by(tile as usize) {
+28 -1
View File
@@ -164,6 +164,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
/// the remaining tiles; the caller discards the result.
/// Destination tiles one triangle render may keep bookkeeping for (a 1M-tile render is 69 Gpx).
const MAX_DST_TILES: u64 = 1 << 20;
/// Source pixels one destination tile may read densely.
const MAX_FOOTPRINT_PIXELS: u64 = 1 << 25;
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
let mut fmt = src.format();
let converted;
@@ -190,6 +195,12 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
y1 = y1.max(v.0[1]);
}
let dst = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
// Bookkeeping is kept per destination tile, so a mesh flung across the whole coordinate range
// would ask for millions of entries before drawing anything: draw nothing instead.
let ts_px = photocraft_geom::TILE_SIZE as u64;
if u64::from(dst.width()).div_ceil(ts_px).saturating_add(1).saturating_mul(u64::from(dst.height()).div_ceil(ts_px).saturating_add(1)) > MAX_DST_TILES {
return out;
}
// Triangles: (vertex indices), binned per destination tile.
let tiles: Vec<Rect> = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect();
let mut bins: Vec<Vec<[usize; 3]>> = vec![Vec::new(); tiles.len()];
@@ -304,7 +315,11 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
return None;
}
let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&src_rect);
if foot.is_empty() || !src.has_tiles_in(foot) {
// Sparse components far apart in the source can land in one tile: the dense window
// between them is not read (the tile stays empty). The pixel-area cap is checked first:
// `has_tiles_in` walks the source tile grid, so an oversized footprint must return
// without paying for that scan.
if foot.is_empty() || foot.size().area() > MAX_FOOTPRINT_PIXELS || !src.has_tiles_in(foot) {
return None;
}
let mut px = src.read_region(foot);
@@ -510,6 +525,18 @@ mod tests {
surf
}
#[test]
fn a_mesh_flung_across_the_coordinate_range_gets_no_per_tile_bookkeeping() {
// About 8000 × 8000 destination tiles from three vertices.
let src = sample(SampleType::U8);
let far = 1_000_000.0;
let verts = [([-far, -far], [0.0, 0.0]), ([far, -far], [40.0, 0.0]), ([0.0, far], [0.0, 24.0])];
let t = std::time::Instant::now();
let out = warp_triangles(&src, Rect::new(0, 0, 40, 24), &verts, &[[0, 1, 2]], Interp::Nearest);
assert!(out.content_bounds().is_empty());
assert!(t.elapsed().as_secs() < 5);
}
#[test]
fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() {
// #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in
@@ -0,0 +1,107 @@
//! Final-render coverage for #7989, extending the boundary fix in #8012.
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn bounds() -> Rect {
// Cross the tile boundary at x=256, with a nonzero origin on both axes.
Rect::new(192, -32, 320, 96)
}
fn source(mode: ColorMode, sample: SampleType) -> Surface {
let format = PixelFormat::new(mode, sample, true);
let mut pixels = Vec::with_capacity(128 * 128 * format.channels());
for y in 0..128 {
for x in 0..128 {
let alpha = if (48..52).contains(&x) { 0.0 } else { ((x + 3 * y) % 5 + 1) as f32 / 5.0 };
for channel in 0..mode.color_channels() {
pixels.push(if alpha == 0.0 { 0.0 } else { ((x * 19 + y * 7 + channel * 43) % 251 + 2) as f32 / 255.0 });
}
pixels.push(alpha);
}
}
let mut source = Surface::new(format);
source.write_region(bounds(), &pixels);
source
}
fn warp(points: &[[f64; 2]], size: f64, pressure: f64) -> LiquifyStroke {
let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, size);
stroke.pressure = pressure;
stroke.points = points.iter().map(|point| point.to_vec()).collect();
stroke
}
fn freeze_left() -> LiquifyStroke {
let mut stroke = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
stroke.amount = Some(1.0);
stroke.points = vec![vec![192.0, -32.0], vec![256.0, -32.0], vec![256.0, 96.0], vec![192.0, 96.0]];
stroke
}
fn check_frozen_output(prior_warp: bool) {
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let source = source(mode, sample);
for cell in [2.5, 4.0] {
let mut strokes = Vec::new();
if prior_warp {
strokes.push(warp(&[[238.0, 20.0], [261.0, 28.0]], 64.0, 55.0));
}
strokes.push(freeze_left());
let mut field = LiquifyField::from_strokes(bounds(), cell, &strokes);
// #8012 establishes the bilinear mapping when the mask is applied.
// This test checks later edits, not exact pre-mask cubic preservation.
let before = apply_liquify(&source, &field);
assert_eq!(before.format(), source.format());
assert_eq!(before == source, !prior_warp, "the fixture must retain an earlier warp and keep a fresh mask-only operation unchanged");
strokes.push(warp(&[[268.0, 32.0], [252.0, 32.0]], 48.0, 80.0));
field.apply_stroke(&strokes[strokes.len() - 1]);
let after = apply_liquify(&source, &field);
assert_eq!(after.format(), source.format());
let (mut protected, mut fractional_alpha, mut unfrozen_changes) = (0, 0, 0);
let mut frozen_warped = 0;
for y in bounds().y0..bounds().y1 {
for x in bounds().x0..bounds().x1 {
let pixel = before.pixel(x, y);
if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 1.0 {
protected += 1;
frozen_warped += usize::from(pixel != source.pixel(x, y));
let alpha = pixel[mode.color_channels()];
fractional_alpha += usize::from(alpha > 0.0 && alpha < 1.0);
// An unchanged F32 pixel can round on premultiply/unpremultiply.
// Integer-depth pixels must remain exactly equal.
let tolerance = if sample == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 };
for (channel, (actual, expected)) in after.pixel(x, y).iter().zip(&pixel).enumerate() {
assert!(
(actual - expected).abs() <= tolerance,
"{mode:?}/{sample:?}, mesh {cell}, prior warp {prior_warp}: frozen channel {channel} changed at {x},{y}: {expected} -> {actual}"
);
}
} else if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 0.0 {
unfrozen_changes += usize::from(after.pixel(x, y) != pixel);
}
}
}
assert!(protected > 7_000 && fractional_alpha > 3_000, "the fixture must cover frozen pixels with fractional alpha");
assert_eq!(frozen_warped > 0, prior_warp, "the fixture must retain the earlier warp inside the fully frozen region");
assert!(unfrozen_changes > 0, "the adjacent unfrozen region must still move");
let replay = LiquifyField::from_strokes(bounds(), cell, &strokes);
assert!(field == replay, "stroke replay must reproduce the field exactly");
assert!(apply_liquify(&source, &replay) == after, "stroke replay must reproduce the final pixels exactly");
}
}
}
}
#[test]
fn fresh_frozen_output_preserves_color_and_alpha_across_formats() {
check_frozen_output(false);
}
#[test]
fn existing_frozen_mapping_survives_adjacent_warps_across_formats() {
check_frozen_output(true);
}
+6
View File
@@ -0,0 +1,6 @@
//! The crate's integration tests, linked into one test binary.
mod liquify_freeze_formats;
mod liquify_source_footprint;
mod retouch_blur_exact;
mod warp_clipped;
+1
View File
@@ -25,6 +25,7 @@ base64 = "0.22"
schemars = "1"
getrandom = { workspace = true }
cap-std = "4"
clap = { workspace = true }
[dev-dependencies]
# Synthetic camera raws (#7789).
+123
View File
@@ -0,0 +1,123 @@
//! The control and automation launch options, as clap types: `photocraft-cli mcp` and `serve`
//! flatten them, and so does the desktop app (#7071), so each flag and its environment variable
//! are declared once. A flag wins over its variable.
//!
//! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset,
//! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from.
//! Check the token pair with [`conflict`] only where a token is used, so a stale exported pair
//! doesn't stop a launch that needs none.
use std::ffi::OsString;
use std::path::PathBuf;
use clap::builder::NonEmptyStringValueParser;
use clap::error::{ContextKind, ContextValue, ErrorKind};
use clap::parser::ValueSource;
use clap::{Arg, ArgMatches, Args, Command};
use crate::{AuthorizedWorkspace, AutomationError};
/// The control token, for a server or for a client of one.
#[derive(Args)]
pub struct ControlArgs {
/// The control token (64 hex digits)
#[arg(long, value_name = "64-HEX", env = "PHOTOCRAFT_CONTROL_TOKEN", hide_env_values = true, value_parser = NonEmptyStringValueParser::new())]
pub control_token: Option<String>,
/// A file holding the control token, used instead of --control-token (a server creates it
/// when missing)
#[arg(long, value_name = "PATH", env = "PHOTOCRAFT_CONTROL_TOKEN_FILE")]
pub control_token_file: Option<PathBuf>,
}
/// The folders automation may touch. Without a root, that direction of access is denied. No
/// variable stands in for them here: one exported for the desktop app must not widen headless
/// `photocraft-cli` access (#1551); the desktop app adds its own.
#[derive(Args)]
pub struct AutomationArgs {
/// The folder automation may read from (none without it)
#[arg(long, value_name = "DIR")]
pub automation_read_root: Option<PathBuf>,
/// The folder automation may write to (none without it)
#[arg(long, value_name = "DIR")]
pub automation_write_root: Option<PathBuf>,
}
impl AutomationArgs {
/// The roots, opened as capabilities.
pub fn workspace(&self) -> Result<AuthorizedWorkspace, AutomationError> {
AuthorizedWorkspace::new(self.automation_read_root.as_deref(), self.automation_write_root.as_deref())
}
}
/// A control port, for clap's `value_parser`: `photocraft --control`, `photocraft-cli serve --port`
/// and PHOTOCRAFT_CONTROL_PORT, which may carry spaces.
pub fn control_port(s: &str) -> Result<u16, NotAPort> {
s.trim().parse().map_err(|_| NotAPort)
}
/// A [`control_port`] value that isn't a port.
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[error("expected a port, 0-65535")]
pub struct NotAPort;
/// For `Command::mut_args`: an empty or blank variable counts as unset, so `PHOTOCRAFT_CONTROL_PORT=`
/// turns a fallback off without `unset` (#2491). clap would pass the empty value to the parser.
pub fn blank_env_is_unset(arg: Arg) -> Arg {
let blank = arg.get_env().and_then(std::env::var_os).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty()));
if blank { arg.env(None::<&str>) } else { arg }
}
/// clap names the flag when a value from its variable is bad or conflicts with another, as if the
/// flag had been given: name the variable instead when the flag isn't among `args` (without the
/// binary name; a first one naming a subcommand selects whose arguments to look in) (#2491).
pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error {
// `--flag value` or `--flag=value`.
let given = |long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('='));
let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd);
for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] {
let Some(ContextValue::String(shown)) = error.get(kind) else { continue };
let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue };
let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue };
if !given(long) {
error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned()));
}
}
error
}
/// clap's either/or usage error when `m`, parsed by `cmd`, has both `a` and `b`, each named as it was
/// given: the flag, or its variable. For a conflict that depends on the mode, such as the token pair
/// (only a server or bridge reads a token), which clap's `conflicts_with` can't express.
pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(), clap::Error> {
if m.value_source(a).is_none() || m.value_source(b).is_none() {
return Ok(());
}
let shown = |id: &str| {
let arg = cmd.get_arguments().find(|arg| arg.get_id() == id);
match (m.value_source(id), arg.and_then(Arg::get_env)) {
(Some(ValueSource::EnvVariable), Some(var)) => var.to_string_lossy().into_owned(),
_ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()),
}
};
let message = format!("the argument '{}' cannot be used with '{}'", shown(a), shown(b));
Err(cmd.error(ErrorKind::ArgumentConflict, message))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_port_accepts_valid_numbers() {
assert_eq!(control_port("50494"), Ok(50494));
assert_eq!(control_port(" 8080 "), Ok(8080));
assert_eq!(control_port("0"), Ok(0));
}
#[test]
fn control_port_rejects_anything_else() {
for bad in ["78787", "-1", "", "x"] {
assert_eq!(control_port(bad), Err(NotAPort), "{bad:?}");
}
}
}
+34 -3
View File
@@ -152,6 +152,28 @@ impl Headless {
})
}
/// Replace a smart object's contents with a file from the read root: the capability-scoped
/// `layer.smartObjects.replaceContents`, which takes an ambient `path` and is refused in
/// automation (#8061). `params` is the command's own (`layer`).
pub fn replace_smart_object(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
self.on_doc(index, |this| {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
return this.command_run("layer.smartObjects.replaceContents", Value::Object(opts));
}
let bytes = match &this.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
}
};
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
this.sync_jobs();
Ok(photocraft_engine::smart_cmds::replace_contents_from_bytes(&mut this.session, &Value::Object(opts), name, bytes)?)
})
}
/// Export each exported layer of a document as its own file beneath the write root: the
/// capability-scoped `file.export.layersToFiles`, which takes an ambient `dir` and is
/// therefore refused in automation (#3216). The bytes come from the command's own core, so
@@ -171,10 +193,16 @@ impl Headless {
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| {
let report = file_cmds::export_layers_report_with(&this.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
let (written, skipped) = match report {
Ok(report) => (Ok(report.files), report.skipped),
Err(error) => (Err(error), Vec::new()),
};
let mut result = exported(dir, written, &mut outputs)?;
result["skipped"] = json!(skipped);
Ok(result)
})
}
@@ -261,7 +289,10 @@ impl Headless {
files::save(&doc, &target, format, opts, Some(writer))?
}
Filesystem::Workspace(workspace) => {
let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?;
// Only the target is granted: a linked smart object's stored path is document
// data, so PSD export writes that layer as pixels instead of embedding the file.
let opts = ExportOptions { read_linked_files: false, ..opts.clone() };
let (bytes, warnings) = files::save_bytes(&doc, target_text, format, &opts)?;
workspace.write(target_text, &bytes)?;
warnings
}
+1
View File
@@ -16,6 +16,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod args;
pub mod bridge;
pub mod budgets;
pub mod files;
+360 -16
View File
@@ -10,6 +10,8 @@
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
//! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope,
//! the counterpart of the refused ambient `file.placeEmbedded`),
//! `doc.replaceSmartObject {path, layer?, index?}` (#8061: the same scope for
//! `layer.smartObjects.replaceContents`),
//! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`,
//! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`,
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
@@ -22,7 +24,9 @@
use std::io::{BufRead, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use base64::Engine as _;
use serde_json::{Value, json};
@@ -43,6 +47,7 @@ pub const METHODS: &[&str] = &[
"session.list",
"doc.open",
"doc.place",
"doc.replaceSmartObject",
"doc.new",
"doc.save",
"doc.exportLayers",
@@ -108,6 +113,13 @@ impl Headless {
opts.remove("index");
self.place(index_of(&p)?, path, &Value::Object(opts))
}
"doc.replaceSmartObject" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.replaceSmartObject needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.replace_smart_object(index_of(&p)?, path, &Value::Object(opts))
}
"doc.new" => self.command_run("file.new", p),
"doc.save" => {
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
@@ -315,32 +327,67 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
}
}
/// Headless TCP daemons with no clients should eventually release their listening port (#3682).
const TCP_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// Serve authenticated JSON lines on a loopback TCP address with one shared session.
/// Refuses non-loopback addresses and caps active connections and request bytes.
/// Refuses non-loopback addresses, caps connections and request bytes, and closes the listener
/// after an idle period with no connections. Active clients are never evicted by this timer.
pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> {
serve_tcp_until_idle(addr, h, token, ready, TCP_IDLE_TIMEOUT)
}
fn serve_tcp_until_idle(
addr: &str,
h: Arc<Mutex<Headless>>,
token: String,
ready: impl FnOnce(std::net::SocketAddr),
idle: Duration,
) -> Result<(), AutomationError> {
let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?;
let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?;
if !local.ip().is_loopback() {
return Err(bad(format!("{addr} is not a loopback address")));
}
listener.set_nonblocking(true).map_err(|e| AutomationError::Io(e.to_string()))?;
ready(local);
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let token = Arc::new(token);
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Some(permit) = limiter.try_acquire() else {
let _ = configure_stream(&stream);
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
continue;
};
let h = h.clone();
let token = Arc::clone(&token);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
});
let active = Arc::new(AtomicUsize::new(0));
let mut last_active = Instant::now();
loop {
match listener.accept() {
Ok((mut stream, _)) => {
last_active = Instant::now();
let Some(permit) = limiter.try_acquire() else {
let _ = configure_stream(&stream);
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
continue;
};
// The listener is nonblocking; individual requests still use blocking socket I/O.
let _ = stream.set_nonblocking(false);
let h = h.clone();
let token = Arc::clone(&token);
let active = Arc::clone(&active);
active.fetch_add(1, Ordering::AcqRel);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
active.fetch_sub(1, Ordering::AcqRel);
});
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if active.load(Ordering::Acquire) != 0 {
last_active = Instant::now();
} else if last_active.elapsed() >= idle {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::ConnectionAborted) => {}
Err(e) => return Err(AutomationError::Io(e.to_string())),
}
}
Ok(())
}
#[cfg(test)]
@@ -724,6 +771,129 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_replace_smart_object_reads_through_the_read_root_and_never_outside_it() {
let base = std::env::temp_dir().join(format!("pc-doc-replace-so-{}", std::process::id()));
let root = base.join("workspace");
let outside = base.join("outside");
for dir in [&root, &outside] {
std::fs::create_dir_all(dir).unwrap();
}
let png = |w: u32, h: u32, v: u8| {
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, vec![v; (w * h * 3) as usize]).unwrap();
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()
};
std::fs::write(root.join("logo.png"), png(8, 6, 42)).unwrap();
std::fs::write(root.join("red.png"), png(8, 6, 200)).unwrap();
std::fs::write(outside.join("red.png"), png(8, 6, 200)).unwrap();
let absolute = outside.join("red.png").to_string_lossy().into_owned();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let layer = h.handle("doc.place", json!({"path": "logo.png"})).unwrap()["layer"].clone();
// The smart object's file name and the composite at its centre: what Replace Contents changes.
let contents = |h: &mut Headless| {
let inspected = h.handle("doc.inspect", json!({})).unwrap();
let layers = inspected["layers"].as_array().unwrap();
let so = layers.iter().find(|l| l["id"] == layer).unwrap_or_else(|| panic!("{inspected}"));
let name = so["smartSource"]["fileName"].as_str().unwrap_or_default().to_owned();
let rendered = h.handle("doc.render", json!({"maxSide": 0})).unwrap();
let png = base64::engine::general_purpose::STANDARD.decode(rendered["base64"].as_str().unwrap()).unwrap();
let img = photocraft_codecs::decode(&png).unwrap();
let (w, ht) = img.dimensions();
let data = img.as_u8().unwrap();
let channels = data.len() / (w * ht) as usize;
(name, data[((ht / 2 * w + w / 2) as usize) * channels])
};
assert_eq!(contents(&mut h), ("logo.png".to_owned(), 42), "the placed contents");
let replaced = h.handle("doc.replaceSmartObject", json!({"path": "red.png", "layer": layer})).unwrap();
assert_eq!(replaced["layer"], layer, "{replaced}");
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "the contents were replaced");
h.handle("engine.execute", json!({"command": "edit.undo"})).unwrap();
assert_eq!(contents(&mut h), ("logo.png".to_owned(), 42), "one undo step restores the old contents");
h.handle("engine.execute", json!({"command": "edit.redo"})).unwrap();
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "redo replaces them again");
// Outside the read root, a bad layer, a missing path or a file that is no image: refused, nothing changed.
for (params, reason) in [
(json!({"path": "../outside/red.png"}), "parent traversal"),
(json!({"path": absolute}), "must be inside the automation root"),
(json!({"path": "logo.png", "layer": 999_999}), "no such layer"),
(json!({}), "needs `path`"),
] {
let refused = h.handle("doc.replaceSmartObject", params).unwrap_err().to_string();
assert!(refused.contains(reason), "{refused}");
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "nothing changed: {refused}");
}
std::fs::write(root.join("junk.png"), b"not an image").unwrap();
assert!(h.handle("doc.replaceSmartObject", json!({"path": "junk.png", "layer": layer})).is_err());
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "a file that is no image changes nothing");
// The ambient command keeps its refusal.
let ambient = h.handle("engine.execute", json!({"command": "layer.smartObjects.replaceContents", "params": {"path": "red.png"}}));
assert!(ambient.unwrap_err().to_string().contains("ambient filesystem paths"));
// Trusted local keeps the ambient command.
let mut t = Headless::trusted_local();
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let tl = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap()["layer"].clone();
let trusted = t.handle("doc.replaceSmartObject", json!({"path": root.join("red.png").to_string_lossy(), "layer": tl})).unwrap();
assert_eq!(trusted["layer"], tl, "{trusted}");
drop(t);
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_layers_reports_skips_in_workspace_and_trusted_sessions() {
for trusted in [false, true] {
let base = std::env::temp_dir().join(format!("pc-export-skips-{}-{trusted}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mut h =
if trusted { Headless::trusted_local() } else { Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&base), Some(&base)).unwrap()) };
h.handle("doc.new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).unwrap();
let adjustment = h.handle("engine.execute", json!({"command": "layer.newAdjustmentLayer.levels"})).unwrap()["layer"].clone();
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"name": "Levels"}})).unwrap();
let hidden = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Hidden"}})).unwrap()["layer"].clone();
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"visible": false}})).unwrap();
// Exercise the actual JSON-lines serve envelope, not only direct dispatch.
let h = Mutex::new(h);
let out_dir = base.join("out").to_string_lossy().into_owned();
let request = json!({"id": 8491, "method": "doc.exportLayers", "params": {"dir": out_dir}}).to_string() + "\n";
let mut output = Vec::new();
serve_lines(&h, request.as_bytes(), &mut output).unwrap();
let reply: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(reply["id"], 8491);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"]["files"], json!([format!("{out_dir}/card_0001_Background.png")]));
if !trusted {
assert_eq!(reply["result"]["count"], 1);
}
assert_eq!(
reply["result"]["skipped"],
json!([
{"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"},
{"layer": adjustment, "name": "Levels", "reason": "adjustment layer has no pixels of its own"}
])
);
assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 1);
let mut h = h.into_inner().unwrap();
let all_dir = base.join("all").to_string_lossy().into_owned();
let all = h.export_layers(None, &all_dir, &json!({"visibleOnly": false})).unwrap();
assert_eq!(all["files"].as_array().unwrap().len(), 2);
assert_eq!(all["skipped"].as_array().unwrap().len(), 1);
// Workspace no-overwrite errors still fail and remove only newly created files.
if !trusted {
let collision = base.join("collision");
std::fs::create_dir_all(&collision).unwrap();
std::fs::write(collision.join("card_0002_Background.png"), b"keep").unwrap();
assert!(h.export_layers(None, &collision.to_string_lossy(), &json!({"visibleOnly": false})).is_err());
assert_eq!(std::fs::read_dir(&collision).unwrap().count(), 1);
assert_eq!(std::fs::read(collision.join("card_0002_Background.png")).unwrap(), b"keep");
}
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
}
#[test]
fn doc_export_layers_writes_only_beneath_the_write_root() {
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
@@ -800,7 +970,7 @@ mod tests {
}
#[test]
fn indexed_place_and_data_set_methods_keep_the_active_document() {
fn indexed_place_replace_and_data_set_methods_keep_the_active_document() {
let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
@@ -817,6 +987,9 @@ mod tests {
let steps = [
("doc.place", json!({"path": "logo.png", "index": 1}), true),
// Document 1's active layer is now the placed Smart Object.
("doc.replaceSmartObject", json!({"path": "logo.png", "index": 1}), true),
("doc.replaceSmartObject", json!({"path": "missing.png", "index": 1}), false),
("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true),
("doc.exportLayers", json!({"dir": "layers", "index": 1}), true),
("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true),
@@ -836,6 +1009,145 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_data_sets_judges_pixel_replacement_paths_like_apply() {
let base = std::env::temp_dir().join(format!("pc-doc-data-set-pixels-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let red = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(16)).unwrap();
let photo = base.join("photo.png");
std::fs::write(&photo, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
let layer = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {}})).unwrap()["layer"].clone();
h.handle(
"engine.execute",
json!({"command": "image.variables.define", "params": {"defs": [{"name": "photo", "layer": layer, "type": "pixelReplacement"}]}}),
)
.unwrap();
// Storing the path is allowed; reading it is what the gate judges.
h.handle(
"engine.execute",
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
{"name": "swap", "values": [{"variable": "photo", "kind": "pixels", "value": photo.to_string_lossy()}]},
]}}),
)
.unwrap();
let applied = h.handle("engine.execute", json!({"command": "image.applyDataSet", "params": {"name": "swap"}})).unwrap_err().to_string();
assert!(applied.contains("ambient filesystem paths"), "{applied}");
let sets = root.join("sets");
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap_err().to_string();
assert!(exported.contains("ambient filesystem paths"), "{exported}");
assert_eq!(std::fs::read_dir(&sets).map_or(0, Iterator::count), 0, "nothing of the refused batch is written");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn workspace_psd_saves_embed_no_file_a_linked_smart_object_names() {
use photocraft_engine::doc::{LayerContent, SmartSource};
// An image PhotoCraft can open (other linked files are never embedded): an embedded copy
// carries these exact bytes, while pixels written for the layer don't.
let image = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, (0..192u8).collect()).unwrap();
let marker = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
let has_marker = |bytes: &[u8]| bytes.windows(marker.len()).any(|w| w == marker.as_slice());
let base = std::env::temp_dir().join(format!("pc-psd-linked-embed-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.png");
std::fs::write(&outside, &marker).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
// A trusted export embeds the linked file, as before.
assert!(has_marker(&photocraft_io::export(&state.doc, "psd", &Default::default()).unwrap().bytes));
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap();
let saved = h.handle("doc.save", json!({"path": "out.psd"})).unwrap();
let warnings = saved["warnings"].to_string();
assert!(warnings.contains("written as pixels"), "{warnings}");
assert!(!has_marker(&std::fs::read(root.join("out.psd")).unwrap()), "the linked file outside the roots is not embedded");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn allowed_filters_do_not_rerender_a_smart_object_from_its_linked_file() {
use photocraft_engine::doc::{LayerContent, SmartSource};
let base = std::env::temp_dir().join(format!("pc-linked-rerender-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let red = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(64)).unwrap();
let outside = base.join("red.png");
std::fs::write(&outside, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
// A document whose white smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("p.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "p.pcraft"})).unwrap();
h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 0.5}})).unwrap();
let doc = &h.session.active().unwrap().doc;
let LayerContent::Smart(smart) = &doc.layers[0].content else { panic!("smart object") };
assert_eq!(smart.smart_filters.len(), 1, "the filter is still recorded");
assert_eq!(smart.cache.as_ref().unwrap().rgba(4, 4), [1.0, 1.0, 1.0, 1.0], "the linked file outside the roots is not read");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn workspace_layer_exports_keep_linked_smart_objects_as_pixels() {
use photocraft_engine::doc::{LayerContent, SmartSource};
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0002";
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
let base = std::env::temp_dir().join(format!("pc-export-layers-linked-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.txt");
std::fs::write(&outside, MARKER).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap();
let out = root.join("layers");
for format in ["psd", "psb"] {
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": format})).unwrap();
assert_eq!(exported["count"], 1, "{exported}");
}
let files: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(files.len(), 2, "{files:?}");
for file in files {
assert!(!has_marker(&std::fs::read(&file).unwrap()), "{} embeds no file outside the roots", file.display());
}
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn trusted_local_render_keeps_full_size_behavior() {
let mut h = Headless::trusted_local();
@@ -874,6 +1186,38 @@ mod tests {
assert!(h.handle("methods", Value::Null).is_ok());
}
#[test]
fn idle_tcp_daemon_releases_its_port_but_keeps_active_clients() {
use std::io::BufReader;
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = Arc::new(Mutex::new(Headless::new()));
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let result = serve_tcp_until_idle("127.0.0.1:0", h, TOKEN.to_owned(), move |addr| ready_tx.send(addr).unwrap(), Duration::from_millis(200));
done_tx.send(result).unwrap();
});
let addr = ready_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let mut client = std::net::TcpStream::connect(addr).unwrap();
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let mut reader = BufReader::new(client.try_clone().unwrap());
writeln!(client, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let auth: Value = serde_json::from_str(&line).unwrap();
assert_eq!(auth["result"]["authenticated"], true);
// The port stays owned well past the idle limit while a client is connected.
assert!(
matches!(done_rx.recv_timeout(Duration::from_millis(450)), Err(std::sync::mpsc::RecvTimeoutError::Timeout)),
"daemon exited with a live client"
);
drop(reader);
drop(client);
assert!(done_rx.recv_timeout(Duration::from_secs(3)).unwrap().is_ok(), "idle daemon did not exit cleanly");
server.join().unwrap();
assert!(TcpListener::bind(addr).is_ok(), "idle listener still owns its port");
}
#[test]
fn tcp_serves_loopback() {
use std::io::{BufReader, Write as _};
+1 -8
View File
@@ -2,7 +2,7 @@
use std::fs::OpenOptions;
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
@@ -308,13 +308,6 @@ impl Read for AuthDeadlineStream {
}
}
/// Environment-aware token inputs shared by the desktop app and CLI.
pub fn token_inputs(supplied: Option<String>, token_file: Option<PathBuf>) -> (Option<String>, Option<PathBuf>) {
let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok());
let token_file = token_file.or_else(|| std::env::var_os("PHOTOCRAFT_CONTROL_TOKEN_FILE").map(PathBuf::from));
(supplied, token_file)
}
#[cfg(test)]
mod tests {
use super::*;
+75 -6
View File
@@ -71,6 +71,20 @@ pub struct PlaceParams {
pub center: Option<[f64; 2]>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReplaceSmartObjectParams {
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
pub path: String,
/// Headless mode only: document index from `session_list` (default: the active document).
/// Bridge mode rejects `index`.
#[serde(default)]
pub index: Option<usize>,
/// The Smart Object layer: an id or a name (default: the active layer).
#[serde(default)]
pub layer: Option<Value>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportLayersParams {
@@ -336,7 +350,8 @@ pub struct UiSetParams {
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). Other fields are an error.
/// over this channel). updatesOpen (bool, the Help › Check for Updates window). Other fields
/// are an error.
pub fields: Value,
}
@@ -412,6 +427,14 @@ fn strict_node(v: &mut Value, defs: &serde_json::Map<String, Value>, depth: usiz
}
}
/// Both the bare engine list and older bridge wrappers own their rows already.
fn into_command_rows(mut reply: Value) -> Vec<Value> {
match reply {
Value::Array(rows) => rows,
_ => reply.get_mut("commands").and_then(Value::as_array_mut).map(std::mem::take).unwrap_or_default(),
}
}
fn ok_json(v: &Value) -> CallToolResult {
let text = match json_bytes(v).and_then(|bytes| String::from_utf8(bytes).map_err(|error| AutomationError::Other(error.to_string()))) {
Ok(text) => text,
@@ -637,7 +660,33 @@ impl PhotocraftMcp {
}
#[tool(
description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite."
description = "Replace a Smart Object's contents with the file at `path` beneath the read root, keeping its transform and filters (the capability-scoped Replace Contents). `layer` is the Smart Object's id or name (default: the active layer)."
)]
async fn doc_replace_smart_object(&self, Parameters(p): Parameters<ReplaceSmartObjectParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.layer {
opts.insert("layer".into(), v);
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which reads the path itself like any command.
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("path".into(), Value::String(p.path.clone()));
}
let (path, index) = (p.path, p.index);
if let Some(r) = self.headless_op(move |h| h.replace_smart_object(index, &path, &opts)).await {
return to_result(r);
}
// The app's command runs on its active document: an `index` it cannot honour is refused
// rather than dropped, or another document's contents would be replaced.
if index.is_some() {
return Ok(fail("`index` is only supported in headless mode; in bridge mode select the document in the app first"));
}
self.run_command("layer.smartObjects.replaceContents".into(), bridge_params, true).await
}
#[tool(
description = "Export top-level layers as files (groups merged). Returns files and skipped: [{layer, name, reason}] for hidden layers (visible_only) and adjustments with no pixels of their own. Workspace exports resolve beneath the write root, also return count of written files, and reject existing files instead of overwriting them. Trusted-local/bridge exports use the engine command's file access policy."
)]
async fn doc_export_layers(&self, Parameters(p): Parameters<ExportLayersParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
@@ -827,10 +876,7 @@ impl PhotocraftMcp {
};
let needle = p.filter.map(|f| f.to_lowercase());
let enabled_only = p.enabled_only.unwrap_or(false);
let items: Vec<Value> = all
.as_array()
.cloned()
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
let items: Vec<Value> = into_command_rows(all)
.into_iter()
.filter(|c| {
needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))
@@ -1083,6 +1129,29 @@ pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Res
mod tests {
use super::*;
#[test]
fn command_rows_keep_bare_and_wrapped_replies_compatible() {
let rows = json!([{"id":"first","params":{"nested":[1,2]}}, {"id":"second","enabled":false}]);
assert_eq!(into_command_rows(rows.clone()), rows.as_array().unwrap().clone());
assert_eq!(into_command_rows(json!({"commands":rows,"other":"ignored"})), rows.as_array().unwrap().clone());
for reply in [json!(null), json!(false), json!(3), json!("invalid"), json!({}), json!({"commands":null}), json!({"commands":{}}), json!([])] {
assert!(into_command_rows(reply).is_empty());
}
}
#[test]
fn command_rows_reuse_the_owned_array_and_nested_strings() {
for wrapped in [false, true] {
let rows = json!([{"id":"first","params":"documentation".repeat(64)}]);
let array = rows.as_array().unwrap().as_ptr();
let text = rows[0]["params"].as_str().unwrap().as_ptr();
let reply = if wrapped { Value::Object([("commands".into(), rows)].into_iter().collect()) } else { rows };
let moved = into_command_rows(reply);
assert_eq!(moved.as_ptr(), array, "wrapped={wrapped}: move the original allocation");
assert_eq!(moved[0]["params"].as_str().unwrap().as_ptr(), text, "wrapped={wrapped}: retain nested allocations");
}
}
#[test]
fn strict_schema_inlines_refs_and_stops_on_recursive_definitions() {
// A definition that refers to itself: inlining stops at a depth instead of looping.
+10 -1
View File
@@ -398,12 +398,13 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
| "gradient.presets.importGrd"
| "plugin.install"
| "swatches.import"
| "style.presets.importAsl"
| "layer.layerStyle.contours.load" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
"actions.save" | "swatches.export" | "style.presets.exportAsl" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
@@ -845,6 +846,9 @@ mod tests {
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})),
("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})),
("style.presets.exportAsl", serde_json::json!({"path": 5})),
("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})),
("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})),
] {
@@ -859,6 +863,8 @@ mod tests {
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
("style.presets.importAsl", serde_json::json!({"data": "QUJD"})),
("style.presets.exportAsl", serde_json::json!({"group": "Basics"})),
("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})),
("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})),
] {
@@ -947,6 +953,7 @@ mod tests {
| "path.rename"
| "path.stroke"
| "type.attachToPath"
| "type.fillPath"
| "type.detachFromPath"
| "select.toWorkPath"
| "layer.vectorMask.add"
@@ -993,6 +1000,8 @@ mod tests {
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})),
("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})),
("prefs.set", serde_json::json!({"path": "historyLog.filePath", "value": "/outside/log"})),
("prefs.set", serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})),
] {

Some files were not shown because too many files have changed in this diff Show More