mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 20:29:42 +02:00
ci: integrate current main while retaining strict test gates
This commit is contained in:
commit
8f58ea4629
|
|
@ -1,14 +1,13 @@
|
|||
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
|
||||
# binaries. CI runs it (`--profile ci`, below); locally it's optional and `cargo test` keeps working
|
||||
# (#2204).
|
||||
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
|
||||
# keeps working (#2204).
|
||||
#
|
||||
# cargo nextest run --workspace # all tests except doctests
|
||||
# cargo test --doc --workspace # nextest does not run doctests
|
||||
#
|
||||
# GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer
|
||||
# serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so
|
||||
# this group caps them at 4. A ui-egui test that builds a `Harness` with `.wgpu()` has a real device:
|
||||
# name it in the filter (both copies) unless its name already says canvas, gpu, mip or type_tool.
|
||||
# this group caps them at 4.
|
||||
[test-groups]
|
||||
gpu = { max-threads = 4 }
|
||||
|
||||
|
|
@ -16,9 +15,10 @@ gpu = { max-threads = 4 }
|
|||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
|
||||
test-group = 'gpu'
|
||||
|
||||
# CI (`--profile ci`): the GPU tests run on WARP, the software adapter, on the Windows runner, and
|
||||
# under load one occasionally hits that same timeout (1 in ~40 runs while measuring #2933). Retry
|
||||
# them; nextest reports a test that passes on retry as FLAKY, so it stays visible.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
|
||||
retries = 2
|
||||
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
|
||||
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
|
||||
# at the end of the log. The default profile's GPU override still applies.
|
||||
[profile.ci]
|
||||
fail-fast = true
|
||||
slow-timeout = { period = "60s", terminate-after = 10 }
|
||||
failure-output = "immediate-final"
|
||||
|
|
|
|||
108
.github/workflows/ci.yml
vendored
108
.github/workflows/ci.yml
vendored
|
|
@ -4,6 +4,7 @@ on:
|
|||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fuzz:
|
||||
|
|
@ -39,6 +40,7 @@ jobs:
|
|||
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
|
||||
# macOS is tested only by release.yml.
|
||||
platforms:
|
||||
timeout-minutes: 10
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
|
|
@ -71,7 +73,7 @@ jobs:
|
|||
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
|
||||
# packages changed: the web app and its packaging, crate sources and manifests (the web
|
||||
# build compiles nearly every crate), the workspace manifests and this workflow.
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "web=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -83,6 +85,7 @@ jobs:
|
|||
fi
|
||||
|
||||
test:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
|
@ -148,37 +151,7 @@ jobs:
|
|||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --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@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20
|
||||
with:
|
||||
tool: cargo-nextest@0.9.146
|
||||
|
||||
# `--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
|
||||
# integration-test target); the feature selection stays the same, so both legs share the
|
||||
# dependency cache. The `ci` profile retries the GPU tests
|
||||
# (.config/nextest.toml).
|
||||
- name: Test
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
run: |
|
||||
case "$PART" in
|
||||
unit) targets=(--lib --bins) ;;
|
||||
integration) targets=(--test '*') ;;
|
||||
*) targets=(--lib --bins --tests) ;;
|
||||
esac
|
||||
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" --no-fail-fast
|
||||
shell: bash
|
||||
|
||||
- name: Doctests
|
||||
if: matrix.part == 'all' || matrix.part == 'unit'
|
||||
run: cargo test --workspace --locked --features heif --doc
|
||||
|
||||
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
|
||||
- name: Clippy
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
|
@ -193,29 +166,63 @@ jobs:
|
|||
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
|
||||
with:
|
||||
tool: cargo-nextest@0.9.146
|
||||
|
||||
# `--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
|
||||
# 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.
|
||||
- name: Test
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
run: |
|
||||
case "$PART" in
|
||||
unit) targets=(--lib --bins) ;;
|
||||
integration) targets=(--test '*') ;;
|
||||
*) targets=(--lib --bins --tests) ;;
|
||||
esac
|
||||
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
|
||||
shell: bash
|
||||
|
||||
- name: Doctests
|
||||
if: matrix.part == 'all' || 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 passed, Linux included, since `needs` reports one result for all
|
||||
# legs; when a PR runs no Windows legs it passes along the Linux result.
|
||||
# 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]
|
||||
needs: [platforms, test, test-variants]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
PLATFORMS: ${{ needs.platforms.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
VARIANTS: ${{ needs.test-variants.result }}
|
||||
run: |
|
||||
echo "platforms: $PLATFORMS, test: $TEST"
|
||||
[ "$PLATFORMS" = success ] && [ "$TEST" = success ]
|
||||
echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
|
||||
[ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
|
||||
|
||||
# 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:
|
||||
|
|
@ -241,8 +248,7 @@ jobs:
|
|||
shared-key: test
|
||||
save-if: false
|
||||
|
||||
- uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
with:
|
||||
tool: cargo-nextest@0.9.146
|
||||
|
||||
|
|
@ -250,11 +256,12 @@ jobs:
|
|||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: matrix.variant == 'without heif'
|
||||
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
||||
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
|
||||
|
|
@ -268,9 +275,12 @@ jobs:
|
|||
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 --no-fail-fast
|
||||
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:
|
||||
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
|
||||
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
|
||||
|
|
@ -303,6 +313,7 @@ jobs:
|
|||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
@ -319,6 +330,7 @@ jobs:
|
|||
run: cargo xtask wasm
|
||||
|
||||
web:
|
||||
timeout-minutes: 60
|
||||
name: web build and wasm size gate
|
||||
# PRs run it only when web-relevant files change (see `platforms`); main always does.
|
||||
needs: platforms
|
||||
|
|
@ -342,8 +354,8 @@ jobs:
|
|||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
# package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
|
||||
# wasm limit and exercises the same packaging step used by release.yml.
|
||||
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
|
||||
# parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
|
||||
- name: Build and verify browser release
|
||||
run: packaging/web/package.sh
|
||||
- name: Report wasm size
|
||||
|
|
@ -354,15 +366,19 @@ jobs:
|
|||
echo
|
||||
echo "| File | Bytes | MiB | Limit |"
|
||||
echo "| --- | ---: | ---: | ---: |"
|
||||
row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
|
||||
# The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
|
||||
for wasm in dist/web/*.wasm; do
|
||||
[ -f "$wasm" ] || continue
|
||||
bytes=$(wc -c < "$wasm" | tr -d ' ')
|
||||
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
|
||||
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
|
||||
row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
|
||||
done
|
||||
# What a host sees: the largest file of the packaged site.
|
||||
largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
|
||||
row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
fuzz:
|
||||
timeout-minutes: 60
|
||||
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
|
||||
# Placeholder: manual trigger only until a nightly schedule is enabled.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
|
||||
|
|
|
|||
1
.github/workflows/docs.yml
vendored
1
.github/workflows/docs.yml
vendored
|
|
@ -27,6 +27,7 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 15
|
||||
name: mdBook build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
|||
1
.github/workflows/packaging-lint.yml
vendored
1
.github/workflows/packaging-lint.yml
vendored
|
|
@ -23,6 +23,7 @@ jobs:
|
|||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
verified-appimage-inputs:
|
||||
timeout-minutes: 10
|
||||
name: Verified AppImage inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ apps/
|
|||
photocraft desktop app (eframe/wgpu), TCP control server
|
||||
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
|
||||
photocraft-web the same app in the browser (trunk + wasm-bindgen)
|
||||
photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ next to them.
|
|||
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
|
|
@ -26,7 +27,7 @@ next to them.
|
|||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
|
|
@ -41,6 +42,7 @@ next to them.
|
|||
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
|
|
@ -112,6 +114,6 @@ next to the files:
|
|||
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
|
||||
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
|
||||
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
|
|
|
|||
73
Cargo.lock
generated
73
Cargo.lock
generated
|
|
@ -4613,7 +4613,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
|
|
@ -4657,7 +4657,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
|
@ -4667,7 +4667,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -4680,7 +4680,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cap-std",
|
||||
|
|
@ -4702,7 +4702,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
|
|
@ -4716,7 +4716,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
|
|
@ -4724,7 +4724,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
|
|
@ -4745,7 +4745,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
|
|
@ -4753,7 +4753,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
|
|
@ -4768,7 +4768,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -4779,7 +4779,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-print",
|
||||
|
|
@ -4814,7 +4814,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
|
|
@ -4835,14 +4835,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
|
|
@ -4859,14 +4859,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
|
|
@ -4895,14 +4895,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -4913,7 +4913,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -4928,7 +4928,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
|
|
@ -4939,7 +4939,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
|
|
@ -4949,7 +4949,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
|
|
@ -4964,7 +4964,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
|
|
@ -4976,7 +4976,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
|
|
@ -4987,7 +4987,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
|
|
@ -5001,7 +5001,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-print",
|
||||
|
|
@ -5042,7 +5042,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
|
|
@ -5053,7 +5053,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -5073,6 +5073,17 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
|
|
@ -6102,9 +6113,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
|
@ -8651,7 +8662,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
17
Cargo.toml
17
Cargo.toml
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
@ -136,17 +136,24 @@ opt-level = "z"
|
|||
[profile.wasm-release.package.naga]
|
||||
opt-level = "z"
|
||||
|
||||
# Pixel processing keeps its speed-oriented optimization level.
|
||||
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
|
||||
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
|
||||
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
|
||||
# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
|
||||
# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
|
||||
# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
|
||||
# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
|
||||
# and reclaim the bytes from a feature-gated decoder instead.
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-cms]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-compose]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-algo]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ flatpak run ai.storyteller.photocraft
|
|||
|
||||
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
|
||||
|
||||
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it.
|
||||
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
|
||||
|
||||
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
|
||||
|
||||
|
|
|
|||
|
|
@ -541,9 +541,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
|
|||
.map(|v| v.iter().collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
|
||||
})
|
||||
.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);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() {
|
|||
// Every flag still works in both forms.
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
|
||||
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]));
|
||||
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([
|
||||
"--format=jpg",
|
||||
"--quality",
|
||||
|
|
|
|||
24
apps/photocraft-web-boot/Cargo.toml
Normal file
24
apps/photocraft-web-boot/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "photocraft-web-boot"
|
||||
description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "photocraft-web-boot"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Inflates the parts in browsers without `DecompressionStream`.
|
||||
flate2 = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
6
apps/photocraft-web-boot/Trunk.toml
Normal file
6
apps/photocraft-web-boot/Trunk.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web-boot"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
30
apps/photocraft-web-boot/index.html
Normal file
30
apps/photocraft-web-boot/index.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>PhotoCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web-boot" data-cargo-profile="wasm-release" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
|
||||
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
|
||||
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
|
||||
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
|
||||
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
|
||||
@keyframes photocraft-stalled { to { visibility: visible; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="photocraft_canvas"></canvas>
|
||||
<div id="photocraft_loading">
|
||||
<p>Loading PhotoCraft…</p>
|
||||
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
|
||||
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
|
||||
console lists what failed.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
356
apps/photocraft-web-boot/src/main.rs
Normal file
356
apps/photocraft-web-boot/src/main.rs
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
|
||||
//!
|
||||
//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
|
||||
//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
|
||||
//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
|
||||
//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
|
||||
//! loader, so the browser still compiles while downloading. No file on the host is larger than
|
||||
//! one part, however large the app grows.
|
||||
//!
|
||||
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
|
||||
//! In one that can't, this module inflates them itself, a little slower.
|
||||
//!
|
||||
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
|
||||
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
|
||||
//!
|
||||
//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
|
||||
//! for hosts without a per-file cap; only the packaged site starts through this module.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
|
||||
/// without this an HTML page would be read as a list of parts.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
const MARKER: &str = "photocraft-parts 1";
|
||||
|
||||
/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
|
||||
/// file next to `parts.txt`: anything that could point elsewhere is refused.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn parts(list: &str) -> Option<Vec<String>> {
|
||||
let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
if lines.next() != Some(MARKER) {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<String> = lines.map(str::to_owned).collect();
|
||||
let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
|
||||
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
|
||||
}
|
||||
|
||||
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
impl Inflater {
|
||||
fn new() -> Self {
|
||||
Inflater(flate2::write::GzDecoder::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
|
||||
/// to input until it has a whole block).
|
||||
fn push(&mut self, chunk: &[u8]) -> std::io::Result<Vec<u8>> {
|
||||
std::io::Write::write_all(&mut self.0, chunk)?;
|
||||
Ok(std::mem::take(self.0.get_mut()))
|
||||
}
|
||||
|
||||
/// The rest, once the stream has ended. An error when it ended early or its checksum is
|
||||
/// wrong, the same as the browser's inflater gives.
|
||||
fn finish(&mut self) -> std::io::Result<Vec<u8>> {
|
||||
self.0.try_finish()?;
|
||||
Ok(std::mem::take(self.0.get_mut()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod boot {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{
|
||||
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
|
||||
TransformStreamDefaultController, Transformer, WritableStream,
|
||||
};
|
||||
|
||||
use super::Inflater;
|
||||
|
||||
/// Where packaging puts the app, next to this module's own files.
|
||||
const PARTS: &str = "./app/parts.txt";
|
||||
/// Seconds to wait before each further try; one more than this many tries are made.
|
||||
const RETRY_AFTER: [u32; 2] = [1, 3];
|
||||
|
||||
// The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
|
||||
// runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
|
||||
// name (Trunk's own name for it carries a content hash).
|
||||
#[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = default, catch)]
|
||||
async fn start_app(module: JsValue) -> Result<JsValue, JsValue>;
|
||||
}
|
||||
|
||||
// The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
|
||||
// have to be set for the whole build, so the three members used are declared here.
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
type DecompressionStream;
|
||||
#[wasm_bindgen(constructor, catch)]
|
||||
fn new(format: &str) -> Result<DecompressionStream, JsValue>;
|
||||
#[wasm_bindgen(method, getter)]
|
||||
fn readable(this: &DecompressionStream) -> ReadableStream;
|
||||
#[wasm_bindgen(method, getter)]
|
||||
fn writable(this: &DecompressionStream) -> WritableStream;
|
||||
}
|
||||
|
||||
fn window() -> Result<web_sys::Window, JsValue> {
|
||||
web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
|
||||
}
|
||||
|
||||
/// `fresh` skips the browser's cache.
|
||||
async fn fetch(url: &str, fresh: bool) -> Result<Response, JsValue> {
|
||||
let init = RequestInit::new();
|
||||
if fresh {
|
||||
init.set_cache(RequestCache::Reload);
|
||||
}
|
||||
let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
|
||||
if !response.ok() {
|
||||
return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
|
||||
}
|
||||
if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
|
||||
return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Copies each part into `sink` in order, then closes it.
|
||||
async fn feed(parts: Vec<String>, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
|
||||
let keep_open = StreamPipeOptions::new();
|
||||
keep_open.set_prevent_close(true);
|
||||
for part in &parts {
|
||||
let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
|
||||
JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
|
||||
}
|
||||
JsFuture::from(sink.close()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Passes inflated bytes on to the stream's reader.
|
||||
fn emit(inflated: std::io::Result<Vec<u8>>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
|
||||
let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
|
||||
if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
|
||||
}
|
||||
|
||||
/// A gzip inflater with the shape of `DecompressionStream`, running in this module.
|
||||
fn own_inflater() -> Result<TransformStream, JsValue> {
|
||||
let inflater = Rc::new(RefCell::new(Inflater::new()));
|
||||
let last = inflater.clone();
|
||||
let busy = || JsValue::from_str("the inflater is in use");
|
||||
let transform = Closure::<dyn FnMut(JsValue, TransformStreamDefaultController) -> Result<(), JsValue>>::new(
|
||||
move |chunk: JsValue, out: TransformStreamDefaultController| {
|
||||
let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
|
||||
emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
|
||||
},
|
||||
);
|
||||
let flush = Closure::<dyn FnMut(TransformStreamDefaultController) -> Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
|
||||
emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
|
||||
});
|
||||
let transformer = Transformer::new();
|
||||
transformer.set_transform(&transform.into_js_value().unchecked_into());
|
||||
transformer.set_flush(&flush.into_js_value().unchecked_into());
|
||||
TransformStream::new_with_transformer(&transformer)
|
||||
}
|
||||
|
||||
/// `compressed` inflated: by the browser where it can, otherwise here.
|
||||
fn inflated(compressed: &ReadableStream) -> Result<ReadableStream, JsValue> {
|
||||
let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
|
||||
let inflater = DecompressionStream::new("gzip")?;
|
||||
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
|
||||
} else {
|
||||
let inflater = own_inflater()?;
|
||||
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
|
||||
};
|
||||
Ok(compressed.pipe_through(&pair))
|
||||
}
|
||||
|
||||
async fn run(fresh: bool) -> Result<(), JsValue> {
|
||||
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
|
||||
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
|
||||
let joined = TransformStream::new()?;
|
||||
let wasm = inflated(&joined.readable())?;
|
||||
// Why the download stopped: the app's loader only reports that its stream broke.
|
||||
let cause = Rc::new(RefCell::new(None));
|
||||
let failed = cause.clone();
|
||||
let sink = joined.writable();
|
||||
spawn_local(async move {
|
||||
if let Err(e) = feed(parts, sink.clone(), fresh).await {
|
||||
let _ = sink.abort_with_reason(&e);
|
||||
failed.replace(Some(e));
|
||||
}
|
||||
});
|
||||
let headers = Headers::new()?;
|
||||
headers.set("Content-Type", "application/wasm")?;
|
||||
let init = ResponseInit::new();
|
||||
init.set_headers(&headers);
|
||||
let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
|
||||
let arg = js_sys::Object::new();
|
||||
js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
|
||||
match start_app(arg.into()).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(cause.take().unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(seconds: u32) {
|
||||
let wait = js_sys::Promise::new(&mut |done, _| {
|
||||
let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
|
||||
if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
|
||||
let _ = done.call0(&JsValue::NULL);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(wait).await;
|
||||
}
|
||||
|
||||
/// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
|
||||
fn say(text: &str, reload: bool) {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
|
||||
loading.set_text_content(None);
|
||||
let Ok(line) = document.create_element("p") else { return };
|
||||
line.set_text_content(Some(text));
|
||||
if reload && let Ok(link) = document.create_element("a") {
|
||||
link.set_text_content(Some("Reload"));
|
||||
let _ = link.set_attribute("href", "");
|
||||
let _ = line.append_child(&link);
|
||||
}
|
||||
let _ = loading.append_child(&line);
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
spawn_local(async {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let error = match run(attempt > 0).await {
|
||||
Ok(()) => return,
|
||||
Err(e) => e,
|
||||
};
|
||||
web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
|
||||
let Some(&wait) = RETRY_AFTER.get(attempt) else {
|
||||
say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
|
||||
return;
|
||||
};
|
||||
say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
|
||||
sleep(wait).await;
|
||||
attempt += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
boot::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Inflater, MARKER, parts};
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut z, bytes).expect("gzip");
|
||||
z.finish().expect("gzip")
|
||||
}
|
||||
|
||||
/// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
|
||||
fn sample() -> Vec<u8> {
|
||||
let mut x = 0x2545_f491_4f6c_dd1d_u64;
|
||||
(0..200_000)
|
||||
.map(|i| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parts arrive in whatever pieces the network gives; the inflater must not care where
|
||||
/// they are cut.
|
||||
#[test]
|
||||
fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
|
||||
let (plain, packed) = (sample(), gzip(&sample()));
|
||||
for cut in [1, 7, 4096, 65_536, packed.len()] {
|
||||
let mut inflater = Inflater::new();
|
||||
let mut out = Vec::new();
|
||||
for chunk in packed.chunks(cut) {
|
||||
out.extend(inflater.push(chunk).expect("push"));
|
||||
}
|
||||
out.extend(inflater.finish().expect("finish"));
|
||||
assert_eq!(out, plain, "chunks of {cut}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A download that stops early, or whose bytes changed on the way, must fail (the boot module
|
||||
/// then tries again), never start a damaged app.
|
||||
#[test]
|
||||
fn the_inflater_refuses_a_short_or_damaged_stream() {
|
||||
let packed = gzip(&sample());
|
||||
let run = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
|
||||
let mut inflater = Inflater::new();
|
||||
let mut out = inflater.push(bytes)?;
|
||||
out.extend(inflater.finish()?);
|
||||
Ok(out)
|
||||
};
|
||||
assert!(run(&packed).is_ok());
|
||||
assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
|
||||
assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
|
||||
let mut damaged = packed.clone();
|
||||
let middle = damaged.len() / 2;
|
||||
damaged[middle] ^= 0x55;
|
||||
assert!(run(&damaged).is_err(), "a changed byte");
|
||||
assert!(run(b"<!DOCTYPE html><html>not gzip</html>").is_err(), "a web page");
|
||||
assert!(run(b"").is_err(), "nothing at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parts_list_gives_its_names_in_order() {
|
||||
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
|
||||
assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
|
||||
}
|
||||
|
||||
/// A host that answers a missing file with its home page must not have that page read as a
|
||||
/// list of parts.
|
||||
#[test]
|
||||
fn a_web_page_is_not_a_parts_list() {
|
||||
assert_eq!(parts(include_str!("../index.html")), None);
|
||||
assert_eq!(parts(""), None);
|
||||
assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
|
||||
assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
|
||||
assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
|
||||
}
|
||||
|
||||
/// Part names are joined onto `./app/`, so one that could leave that folder, or name another
|
||||
/// origin, refuses the whole list.
|
||||
#[test]
|
||||
fn a_part_name_cannot_point_outside_the_app_folder() {
|
||||
for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
|
||||
assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The packaged site shows this crate's page, the development build the app's own. They are
|
||||
/// the same page apart from which program the `<link data-trunk>` line builds.
|
||||
#[test]
|
||||
fn the_page_is_the_apps_page() {
|
||||
let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::<Vec<_>>().join("\n");
|
||||
let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
|
||||
assert_eq!(trunk(boot), trunk(app));
|
||||
assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
|
||||
assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
|
||||
}
|
||||
}
|
||||
|
|
@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
|||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
|
||||
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
|
||||
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
|
@ -340,20 +340,7 @@ fn services(inbox: Inbox) -> Services {
|
|||
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
opts.image_previews = settings.image_previews;
|
||||
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
file_dialog: Some(Box::new(|request, _parent, reply| match request {
|
||||
// The browser's file picker hands over the files' contents, not paths. File › Open
|
||||
|
|
|
|||
|
|
@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
|
||||
/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
|
||||
/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
|
||||
/// replaced it with a flat picture (#5849). `exe` is the running executable.
|
||||
pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
|
||||
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
|
||||
}
|
||||
|
||||
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
|
||||
fn in_app_bundle(exe: &std::path::Path) -> bool {
|
||||
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
|
||||
let macos = exe.parent();
|
||||
let contents = macos.and_then(std::path::Path::parent);
|
||||
let bundle = contents.and_then(std::path::Path::parent);
|
||||
name(macos).as_deref() == Some("MacOS")
|
||||
&& name(contents).as_deref() == Some("Contents")
|
||||
&& name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
|
@ -45,6 +64,22 @@ mod tests {
|
|||
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
|
||||
}
|
||||
|
||||
/// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
|
||||
/// Linux and Windows builds) still set the runtime icon.
|
||||
#[test]
|
||||
fn only_a_macos_app_bundle_skips_the_runtime_icon() {
|
||||
use std::path::Path;
|
||||
assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
|
||||
assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
|
||||
assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
|
||||
assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
|
||||
assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
|
||||
assert!(!in_app_bundle(Path::new("photocraft")));
|
||||
assert!(sets_runtime_icon(None));
|
||||
assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
|
||||
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_icon_decodes() {
|
||||
let icon = window_icon();
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ use std::io::{BufReader, Write};
|
|||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
|
||||
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
|
||||
read_bounded_line, repeat_auth_reply,
|
||||
};
|
||||
use photocraft_ui_egui::ControlRequest;
|
||||
use photocraft_ui_egui::control::REPLY_DEADLINE;
|
||||
|
|
@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
|
|||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
|
||||
}
|
||||
|
||||
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
|
||||
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
|
||||
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
|
||||
if configure_stream(&stream).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
|
||||
let mut out = stream;
|
||||
let mut line = String::new();
|
||||
let mut authenticated = false;
|
||||
|
|
@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
|||
if !authenticated {
|
||||
let (reply, ok) = authentication_reply(&line, token);
|
||||
authenticated = ok;
|
||||
if ok && reader.get_mut().authenticated().is_err() {
|
||||
break;
|
||||
}
|
||||
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
|
||||
break;
|
||||
}
|
||||
|
|
@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
|||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
use std::time::Duration;
|
||||
|
||||
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
|
||||
/// (releasing its connection permit), and nothing it sent is dispatched.
|
||||
#[test]
|
||||
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let started = Instant::now();
|
||||
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
|
||||
started.elapsed()
|
||||
});
|
||||
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
|
||||
// the idle timeout.
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
for i in 0..150 {
|
||||
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
|
||||
if stream.write_all(byte).is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let served = server.join().unwrap();
|
||||
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
|
||||
assert!(rx.try_recv().is_err(), "nothing was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
|
||||
|
|
|
|||
|
|
@ -233,9 +233,10 @@ pub struct Candidate {
|
|||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
|
||||
/// for presenting the window (see [`backends`]).
|
||||
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
|
||||
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
|
||||
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
|
||||
let ty = match a.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
|
|
@ -442,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
|
||||
/// turned on), read before the window opens: it picks the surface's present mode.
|
||||
pub fn read_low_latency(path: Option<&Path>) -> bool {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
|
||||
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
|
|
@ -462,6 +471,23 @@ mod tests {
|
|||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_low_latency(Some(&file))
|
||||
};
|
||||
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
|
||||
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
|
||||
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
|
||||
assert!(!read("not json"));
|
||||
assert!(!read_low_latency(None), "no settings file: the default (off)");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_server_preference_is_read_before_the_window_opens() {
|
||||
use photocraft_engine::prefs::LinuxDisplayServer;
|
||||
|
|
@ -635,6 +661,20 @@ mod tests {
|
|||
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
|
||||
}
|
||||
|
||||
/// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
|
||||
/// GL drew a black window; WARP works. GL stays the last resort.
|
||||
#[test]
|
||||
fn windows_auto_prefers_warp_over_gl() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let svga_gl = cand(0x15ad, T::Other, B::Gl);
|
||||
let warp = cand(0x1414, T::Cpu, B::Dx12);
|
||||
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
|
||||
// Elsewhere GL keeps its rank.
|
||||
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_must_present_even_when_none_support_the_surface() {
|
||||
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
//!
|
||||
//! 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] [files…]`
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
|
||||
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [files…]`
|
||||
//!
|
||||
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
|
||||
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
|
||||
//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`;
|
||||
//! `photocraft_ui_egui::stroke_trace`).
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
|
|
@ -44,6 +50,7 @@ mod linux_libs;
|
|||
mod logging;
|
||||
mod monitor_profile;
|
||||
mod photoshop_settings;
|
||||
mod presentation;
|
||||
mod screen_color;
|
||||
mod services;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
|
|
@ -78,9 +85,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
|
|||
/// different offset every launch (#419). A later launch restores the saved position instead
|
||||
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
let viewport = egui::ViewportBuilder::default();
|
||||
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
|
||||
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon::window_icon())
|
||||
viewport: viewport
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
|
|
@ -189,6 +198,7 @@ fn main() -> eframe::Result {
|
|||
// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
let mut unreadable_paths: Vec<String> = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
|
||||
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
|
|
@ -209,6 +219,18 @@ fn main() -> eframe::Result {
|
|||
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--safe-gpu") => safe_gpu = true,
|
||||
Some(flag @ ("--present-mode" | "--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" => present_mode_arg = Some(v),
|
||||
"--frame-latency" => frame_latency_arg = Some(v),
|
||||
_ => 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") => in_window_menus = true,
|
||||
Some("--version") => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
|
|
@ -362,6 +384,21 @@ fn main() -> eframe::Result {
|
|||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
|
||||
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
|
||||
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
|
||||
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());
|
||||
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.
|
||||
let uncapped = !matches!(
|
||||
options.wgpu_options.surface.present_mode,
|
||||
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
|
||||
);
|
||||
log::info!("presentation: {present_desc}");
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
|
||||
|
|
@ -390,6 +427,7 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
}
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
|
||||
|
|
@ -421,6 +459,10 @@ fn main() -> eframe::Result {
|
|||
// composites on the CPU).
|
||||
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 {
|
||||
app.stroke_trace.set(v);
|
||||
}
|
||||
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
|
||||
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
|
||||
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
|
||||
|
|
|
|||
121
apps/photocraft/src/presentation.rs
Normal file
121
apps/photocraft/src/presentation.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel
|
||||
//! 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.
|
||||
//! The result is logged at start and shown in the stroke trace.
|
||||
|
||||
use eframe::egui_wgpu::SurfaceConfig;
|
||||
use eframe::wgpu::PresentMode;
|
||||
|
||||
/// The present mode and frame latency this launch asked for (`None`: the default).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Overrides {
|
||||
pub mode: Option<PresentMode>,
|
||||
pub latency: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
});
|
||||
(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()));
|
||||
for w in warnings {
|
||||
log::warn!("{w}");
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// `surface` with the overrides applied.
|
||||
pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig {
|
||||
SurfaceConfig {
|
||||
present_mode: self.mode.unwrap_or(surface.present_mode),
|
||||
desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with
|
||||
/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency.
|
||||
pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String {
|
||||
let mode = match surface.present_mode {
|
||||
PresentMode::AutoVsync => "autovsync",
|
||||
PresentMode::AutoNoVsync => "autonovsync",
|
||||
PresentMode::Fifo => "fifo",
|
||||
PresentMode::FifoRelaxed => "fiforelaxed",
|
||||
PresentMode::Immediate => "immediate",
|
||||
PresentMode::Mailbox => "mailbox",
|
||||
};
|
||||
let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string());
|
||||
let tag = |set: bool| if set { " (override)" } else { "" };
|
||||
format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn values_parse_and_bad_ones_are_ignored_with_a_warning() {
|
||||
assert_eq!(parse(None, None), (Overrides::default(), vec![]));
|
||||
assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![]));
|
||||
let (o, w) = parse(Some(" Mailbox "), Some("1"));
|
||||
assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0));
|
||||
assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo));
|
||||
// Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support.
|
||||
for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] {
|
||||
assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}");
|
||||
}
|
||||
for (mode, latency) in
|
||||
[(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))]
|
||||
{
|
||||
let (o, w) = parse(mode, latency);
|
||||
assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_replace_only_what_they_set_and_are_described() {
|
||||
let default = SurfaceConfig::HIGH_THROUGHPUT;
|
||||
assert_eq!(Overrides::default().apply(default), default);
|
||||
let latency_only = Overrides { mode: None, latency: Some(1) };
|
||||
let s = latency_only.apply(default);
|
||||
assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1)));
|
||||
assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)");
|
||||
let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None };
|
||||
assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2");
|
||||
let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None };
|
||||
assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default");
|
||||
}
|
||||
}
|
||||
|
|
@ -21,8 +21,8 @@ mod recovery_tests;
|
|||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw",
|
||||
"pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
|
||||
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
|
|
@ -53,6 +53,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
|
|||
/// keeps defaulting to PSD.
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("PSD Document", &["psd", "psb"]),
|
||||
("PDF", &["pdf"]),
|
||||
("PhotoCraft", &["pcraft"]),
|
||||
("OpenRaster", &["ora"]),
|
||||
("PNG", &["png"]),
|
||||
|
|
@ -228,20 +229,7 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
|
|||
}
|
||||
|
||||
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
opts.image_previews = settings.image_previews;
|
||||
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
|
||||
opts
|
||||
settings.io_options()
|
||||
}
|
||||
|
||||
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
|
||||
|
|
@ -738,7 +726,8 @@ mod tests {
|
|||
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
|
||||
#[test]
|
||||
fn save_as_offers_only_writable_formats_including_ico() {
|
||||
let layered = ["psd", "psb", "pcraft", "ora"];
|
||||
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
|
||||
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
|
||||
for (name, exts) in SAVE_FILTERS {
|
||||
for ext in exts.iter().filter(|e| !layered.contains(e)) {
|
||||
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
|
||||
|
|
@ -907,6 +896,7 @@ mod tests {
|
|||
let mut app = launch(&recovery);
|
||||
new_doc(&mut app, "#ff0000");
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
|
||||
autosave(&mut app, &ctx);
|
||||
|
||||
let mut failed = false;
|
||||
|
|
@ -927,12 +917,16 @@ mod tests {
|
|||
std::fs::remove_file(&recovery).unwrap();
|
||||
std::fs::create_dir(&recovery).unwrap();
|
||||
autosave(&mut app, &ctx);
|
||||
// The worker commits the bundle's manifest before it publishes the sidecar, and
|
||||
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
|
||||
// the first non-empty listing can come from the middle of the write. Wait for the write
|
||||
// to finish: the entry then carries the sidecar's revision.
|
||||
let mut recovered = Vec::new();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
recovered = list_recovery(&recovery);
|
||||
if !recovered.is_empty() {
|
||||
if recovered.iter().any(|e| e.info.revision == revision) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
|
|
|||
|
|
@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
|
|||
#[cfg(any(target_os = "linux", test))]
|
||||
const BTN_TOOL_PEN: u32 = 0x140;
|
||||
|
||||
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
|
||||
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
|
||||
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const ABS_X: u32 = 0x00;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const ABS_Y: u32 = 0x01;
|
||||
|
||||
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
|
||||
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`
|
||||
/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard
|
||||
/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but
|
||||
/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a
|
||||
/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639).
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn has_pen(devices: &str) -> bool {
|
||||
let bits = usize::BITS;
|
||||
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
|
||||
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
|
||||
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
|
||||
devices.split("\n\n").any(|device| {
|
||||
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
|
||||
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
|
||||
/// significant first. Short or malformed bitmaps have no bits set.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn bit_set(bitmap: &str, bit: u32) -> bool {
|
||||
let bits = usize::BITS;
|
||||
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
|
||||
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
|
||||
}
|
||||
|
||||
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
|
||||
/// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another
|
||||
/// X app would outlive a mouse stroke here), so nothing starts.
|
||||
|
|
@ -238,6 +256,49 @@ mod tests {
|
|||
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
|
||||
assert!(!has_pen(keys), "{keys:?}");
|
||||
}
|
||||
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
|
||||
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has
|
||||
/// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window
|
||||
/// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are
|
||||
/// the device's own, from `/proc/bus/input/devices`.
|
||||
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
|
||||
N: Name=\"RustDesk UInput Keyboard\"\n\
|
||||
P: Phys=\n\
|
||||
S: Sysfs=/devices/virtual/input/input54\n\
|
||||
U: Uniq=\n\
|
||||
H: Handlers=sysrq rfkill kbd event256 leds \n\
|
||||
B: PROP=0\n\
|
||||
B: EV=20013\n\
|
||||
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\
|
||||
B: MSC=10\n\
|
||||
B: LED=7\n";
|
||||
|
||||
#[test]
|
||||
fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() {
|
||||
assert!(
|
||||
bit_set(
|
||||
"ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe",
|
||||
BTN_TOOL_PEN
|
||||
),
|
||||
"the fixture claims the key"
|
||||
);
|
||||
assert!(!has_pen(VIRTUAL_KEYBOARD));
|
||||
// Beside a real pen, the pen is still found; the keyboard's key bits never leak into the
|
||||
// pen's device and the pen's axes never into the keyboard's.
|
||||
let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}");
|
||||
assert!(has_pen(&both));
|
||||
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
|
||||
assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions");
|
||||
// A pen display or pen-enabled touchscreen reports the pen with its own axes.
|
||||
let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n";
|
||||
assert!(has_pen(pen_display));
|
||||
// Axes without `ABS_X` or `ABS_Y` don't count.
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -81,17 +81,16 @@ mod platform {
|
|||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE::new(),
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
|
||||
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
|
||||
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
|
||||
/// parked but being dragged or snapped, and is left alone.
|
||||
const FRAME: i32 = 32;
|
||||
|
||||
/// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under
|
||||
/// the taskbar back to the work area plus its frame (#3144). No-op otherwise.
|
||||
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
|
||||
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
|
||||
if !hwnd.IsZoomed() {
|
||||
return;
|
||||
|
|
@ -100,34 +99,58 @@ mod platform {
|
|||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else {
|
||||
let Some((left, top, right, bottom)) =
|
||||
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
|
||||
return;
|
||||
};
|
||||
// Moving the outer frame while winit pins the client rect to the work area must
|
||||
// not copy the old frame's pixels into the custom title bar.
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE { cx: r.right - r.left, cy: height },
|
||||
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
POINT { x: left, y: top },
|
||||
SIZE { cx: width, cy: height },
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
let _ = hwnd.InvalidateRect(None, false);
|
||||
}
|
||||
|
||||
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it
|
||||
/// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at
|
||||
/// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the
|
||||
/// monitor less the taskbar) in the same coordinates. The parked check is against the full
|
||||
/// monitor, because that is where Windows puts a maximize; the trim is against the work area.
|
||||
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
|
||||
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
|
||||
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
|
||||
/// both makes the guard idempotent and handles Windows already correcting some edges.
|
||||
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
|
||||
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
|
||||
let (l, t, r, b) = rect;
|
||||
let (ml, mt, mr, _) = monitor;
|
||||
let (_, _, _, wb) = work;
|
||||
let side = ml - l;
|
||||
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
|
||||
let (ml, mt, mr, mb) = monitor;
|
||||
let (wl, wt, wr, wb) = work;
|
||||
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
|
||||
return None;
|
||||
}
|
||||
if b <= wb + side {
|
||||
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
|
||||
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
|
||||
let border = [
|
||||
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
|
||||
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
|
||||
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
|
||||
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|n| (0..=FRAME).contains(n))
|
||||
.min()?;
|
||||
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
|
||||
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|
||||
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|
||||
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|
||||
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(wb + side - t)
|
||||
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
|
||||
(corrected != rect).then_some(corrected)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
|
|
@ -139,7 +162,7 @@ mod platform {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{borderless, clamped_height};
|
||||
use super::{borderless, clamped_rect};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
|
|
@ -156,34 +179,47 @@ mod platform {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
|
||||
// 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole
|
||||
// monitor, taskbar included (#3144). Trimmed so the rect ends at the work area
|
||||
// plus the frame the window was parked with.
|
||||
fn a_maximize_respects_taskbars_on_every_edge() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (work, expected) in [
|
||||
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
|
||||
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
|
||||
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
|
||||
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
|
||||
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
|
||||
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
|
||||
] {
|
||||
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
|
||||
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
|
||||
// What Windows sizes a framed maximize to: the work area plus the frame.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
|
||||
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
|
||||
let monitor = (-1920, -1080, 0, 0);
|
||||
let work = (-1920, -1032, 0, 0);
|
||||
for border in [0, 8, 16, 32] {
|
||||
let rect = (-1920 - border, -1080 - border, border, border);
|
||||
let expected = (-1920 - border, -1032 - border, border, border);
|
||||
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
|
||||
assert_eq!(clamped_rect(expected, monitor, work), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
|
||||
// Taskbar on the left: the work area starts further right, but the monitor's bottom
|
||||
// is still clear, so the rect reaches nothing.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_being_dragged_away_is_left_alone() {
|
||||
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
|
||||
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 48, 1920, 1080);
|
||||
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
|
||||
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
assets/icons/quick-selection.svg
Normal file
16
assets/icons/quick-selection.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m11 10 3 3" />
|
||||
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
|
||||
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
|
||||
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
|
|
@ -158,3 +158,7 @@ sync_github_name = false
|
|||
[people.yeniufuklar-com]
|
||||
real_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.sidney]
|
||||
real_name = "Sidney Markowitz"
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ synth = []
|
|||
[dependencies]
|
||||
crc32fast = "1.5"
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = "0.9"
|
||||
ruzstd = "0.9.1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -132,8 +132,10 @@ test fixtures, not an exporter.
|
|||
|
||||
## Safety limits
|
||||
|
||||
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
|
||||
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import
|
||||
and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels
|
||||
are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB
|
||||
zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
|
||||
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
|
||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target;
|
|||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = photocraft_affinity::container::header(data);
|
||||
let _ = photocraft_affinity::preview(data);
|
||||
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target;
|
|||
fuzz_target!(|stream: &[u8]| {
|
||||
let _ = photocraft_affinity::stream::parse(stream);
|
||||
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
|
||||
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,11 +26,16 @@ pub struct Limits {
|
|||
pub max_entry: usize,
|
||||
/// Total bytes extracted over the whole import.
|
||||
pub max_total: usize,
|
||||
/// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the
|
||||
/// temporary planes the layer being read decodes through. The entry limits bound what is
|
||||
/// extracted, not what the layers materialized out of it cost (#4960).
|
||||
pub max_pixel_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { max_entry: 256 << 20, max_total: 1 << 30 }
|
||||
// The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`).
|
||||
Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
|||
let mut archive = Archive::open(bytes, limits)?;
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
Reader::new(&s, &mut archive).document()
|
||||
Reader::new(&s, &mut archive, limits).document()
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
|
|
@ -298,11 +298,27 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
|||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
|
||||
/// far hold).
|
||||
pixels_left: usize,
|
||||
}
|
||||
|
||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
|
||||
}
|
||||
|
||||
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
|
||||
/// output, which stays with the document; `planes` is the working set it decodes through and
|
||||
/// releases. Every layer read so far is still live while this one decodes, so the peak is all
|
||||
/// three together.
|
||||
pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> {
|
||||
let peak = rgba.saturating_add(planes);
|
||||
if peak > self.pixels_left {
|
||||
return Err(Error::Limit("decoded pixel layers exceed the pixel budget"));
|
||||
}
|
||||
self.pixels_left -= rgba;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
|||
return Ok(None);
|
||||
}
|
||||
};
|
||||
// A layer is materialized from its declared size, whatever the tiles hold: admit the output
|
||||
// and the planes it decodes through before either is allocated (#4960).
|
||||
let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?;
|
||||
let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?;
|
||||
r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?;
|
||||
let mut planes = Vec::with_capacity(channels);
|
||||
let mut cache = HashMap::new();
|
||||
for c in 1..=channels {
|
||||
|
|
|
|||
|
|
@ -251,9 +251,9 @@ mod archive {
|
|||
broken[90] ^= 0x55;
|
||||
let mut a = Archive::open(&broken, Limits::default()).unwrap();
|
||||
assert!(a.read("doc.dat").is_err());
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap();
|
||||
a.read("doc.dat").unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
|
|
@ -459,3 +459,82 @@ mod shapes {
|
|||
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive
|
||||
/// extracts; these cover what the decoded layers and their working planes cost, which is declared
|
||||
/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all.
|
||||
mod pixels {
|
||||
use crate::Error;
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind};
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
/// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles.
|
||||
fn layer(id: u32, w: i32, h: i32) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"Rstr")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str(format!("Layer {id}"))),
|
||||
(tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A document whose one page holds a pixel layer per `sizes` entry.
|
||||
fn document(sizes: &[(i32, i32)]) -> Vec<u8> {
|
||||
let children: Vec<F> = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect();
|
||||
let data = synth::stream(&[(
|
||||
tag(b"DocR"),
|
||||
F::Def(
|
||||
1,
|
||||
vec![tag(b"DocN")],
|
||||
vec![(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
)]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
|
||||
}
|
||||
|
||||
fn imported_layers(d: &Document) -> usize {
|
||||
d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count()
|
||||
}
|
||||
|
||||
/// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two.
|
||||
const ONE_LAYER: usize = 40 << 10;
|
||||
|
||||
#[test]
|
||||
fn a_pixel_layer_within_the_budget_imports() {
|
||||
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
|
||||
let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap();
|
||||
assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() {
|
||||
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
|
||||
// The document is a few hundred bytes: the sizes are in the headers, not the entries.
|
||||
let bytes = document(&[(64, 64), (64, 64)]);
|
||||
assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len());
|
||||
assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized");
|
||||
// Two layers fit once the budget covers their pixels together.
|
||||
let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() };
|
||||
assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() {
|
||||
// 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget.
|
||||
let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() };
|
||||
assert!(
|
||||
matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))),
|
||||
"16 MiB of declared pixels materialized under a 1 MiB budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -474,8 +474,10 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
|
|||
let mut cost_fg = vec![0.0f32; sw * sh];
|
||||
let mut cost_bg = vec![0.0f32; sw * sh];
|
||||
let mut fixed = vec![FREE; sw * sh];
|
||||
// Always the hole's own edge, and around a thin selection each part's surroundings.
|
||||
let keep = thin_band(sw, sh, &shole);
|
||||
for i in 0..sw * sh {
|
||||
if shole[i] || dist[i] <= 1.5 {
|
||||
if shole[i] || dist[i] <= keep {
|
||||
fixed[i] = HARD_FG;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -508,6 +510,22 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// How far around a thin selection (strings, wires, and any small parts selected with them, all
|
||||
/// at most a quarter as thick as the selection is long) the Auto area always reaches, in cells of
|
||||
/// a `w × h` grid: as far as the selection is thick, so that each part, however far from the
|
||||
/// rest, keeps its own surroundings to copy from. 1.5 cells for any other selection.
|
||||
fn thin_band(w: usize, h: usize, hole: &[bool]) -> f32 {
|
||||
let inside = crate::selection::edt(&hole.iter().map(|h| !h).collect::<Vec<_>>(), w, h);
|
||||
let thickness = 2.0 * inside.iter().zip(hole).filter(|(_, h)| **h).map(|(d, _)| *d).fold(0.0f32, f32::max);
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (i, _) in hole.iter().enumerate().filter(|(_, h)| **h) {
|
||||
let (x, y) = (i % w, i / w);
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
}
|
||||
let extent = x1.saturating_sub(x0).max(y1.saturating_sub(y0)) as f32;
|
||||
if 4.0 * thickness <= extent { thickness.max(1.5) } else { 1.5 }
|
||||
}
|
||||
|
||||
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
|
||||
/// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0
|
||||
/// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a
|
||||
|
|
@ -751,6 +769,31 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sampling_keeps_the_surroundings_of_every_part_of_a_thin_selection() {
|
||||
// A long thin selection across a grey ramp, and a small knob selected with it in a red
|
||||
// corner. Each part keeps its own surroundings; a compact selection is unchanged.
|
||||
let (w, h) = (900, 600);
|
||||
let tex = noise(w, h, 4);
|
||||
let img: Vec<f32> = (0..w * h * 3)
|
||||
.map(|i| {
|
||||
let (x, y, c) = ((i / 3) % w, (i / 3) / w, i % 3);
|
||||
let base = if x > 690 && y > 420 { [0.7, 0.25, 0.2][c] } else { 0.2 + 0.5 * x as f32 / w as f32 };
|
||||
base + (tex[i] - 0.5) * 0.04
|
||||
})
|
||||
.collect();
|
||||
let line = |i: usize| (30..870).contains(&(i % w)) && (180..192).contains(&(i / w));
|
||||
let knob = |i: usize| ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 10.0;
|
||||
let hole: Vec<bool> = (0..w * h).map(|i| line(i) || knob(i)).collect();
|
||||
let area = auto_sampling(w, h, 3, &img, &hole);
|
||||
let around_knob = (0..w * h).filter(|&i| !hole[i] && ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 24.0);
|
||||
let (n, kept) = around_knob.fold((0, 0), |(n, k), i| (n + 1, k + usize::from(area[i])));
|
||||
assert!(kept as f32 > 0.9 * n as f32, "{kept} of {n} pixels around the knob");
|
||||
assert!(thin_band(w, h, &hole) >= 20.0, "{}", thin_band(w, h, &hole));
|
||||
let disc: Vec<bool> = (0..w * h).map(|i| ((i % w) as f32 - 450.0).hypot((i / w) as f32 - 300.0) < 90.0).collect();
|
||||
assert_eq!(thin_band(w, h, &disc), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_colour_adaptation_the_fill_copies_pixels_exactly() {
|
||||
// A smooth texture with fine noise, so that every value is unique: a filled pixel equal to
|
||||
|
|
@ -798,6 +841,116 @@ mod tests {
|
|||
assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10));
|
||||
}
|
||||
|
||||
/// A soft background (gradients, broad out-of-focus blobs of colour, fine noise) with thin, long bright
|
||||
/// objects over it: three coiled strings 3 to 7 px wide and two straighter ones, each with an
|
||||
/// anti-aliased edge and a faint halo. Returns the image; what removing the coils should give
|
||||
/// (the background and the straighter strings, which stay); and the selection a user would draw
|
||||
/// around the coils, `margin` px wide.
|
||||
fn strings_scene(w: usize, h: usize, margin: f32) -> (Vec<f32>, Vec<f32>, Vec<bool>) {
|
||||
let fine = noise(w, h, 11);
|
||||
// Out-of-focus colour: broad blobs over two gradients.
|
||||
let blob = noise(3, 3, 5);
|
||||
let bg: Vec<f32> = (0..w * h * 3)
|
||||
.map(|i| {
|
||||
let (x, y, c) = (((i / 3) % w) as f32 / w as f32, ((i / 3) / w) as f32 / h as f32, i % 3);
|
||||
let mut v = [0.18 + 0.25 * x, 0.22 + 0.1 * y, 0.3 - 0.1 * x][c];
|
||||
for b in blob.as_chunks::<3>().0 {
|
||||
let (bx, by, s) = (b[0] * 1.6 - 0.3, b[1] * 1.6 - 0.3, 0.1 + 0.25 * b[2]);
|
||||
v += (b[(c + 1) % 3] - 0.2) * 0.5 * (-((x - bx).powi(2) + (y - by).powi(2) * 0.56) / (2.0 * s * s)).exp();
|
||||
}
|
||||
v + (fine[i] - 0.5) * 0.03
|
||||
})
|
||||
.collect();
|
||||
// Signed distance to each object's surface, stamped from points along it.
|
||||
let stamp = |sd: &mut [f32], pts: &mut dyn Iterator<Item = (f32, f32)>, half: f32| {
|
||||
for (px, py) in pts {
|
||||
let reach = (half + 4.0) as i32 + 1;
|
||||
for dy in -reach..=reach {
|
||||
for dx in -reach..=reach {
|
||||
let (x, y) = (px as i32 + dx, py as i32 + dy);
|
||||
if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h {
|
||||
let i = y as usize * w + x as usize;
|
||||
sd[i] = sd[i].min((x as f32 - px).hypot(y as f32 - py) - half);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut coils = vec![f32::INFINITY; w * h];
|
||||
for (k, half) in [1.5f32, 2.5, 3.5].into_iter().enumerate() {
|
||||
let (y0, r) = ((0.22 + 0.28 * k as f32) * h as f32, 10.0 + 2.0 * k as f32);
|
||||
let mut pts = (0..4000).map(|j| {
|
||||
let t = j as f32 / 4000.0;
|
||||
let a = 8.0 * std::f32::consts::TAU * t;
|
||||
(18.0 + t * (w as f32 - 60.0) + r * a.cos(), y0 + 6.0 * (6.0 * t).sin() + 0.7 * r * a.sin())
|
||||
});
|
||||
stamp(&mut coils, &mut pts, half);
|
||||
}
|
||||
let mut straight = vec![f32::INFINITY; w * h];
|
||||
for y in [0.07, 0.93] {
|
||||
stamp(&mut straight, &mut (0..2000).map(|j| (j as f32 / 2000.0 * w as f32, y * h as f32 + 3.0 * (j as f32 / 300.0).sin())), 2.0);
|
||||
}
|
||||
let draw = |base: &[f32], sd: &[f32]| -> Vec<f32> {
|
||||
let mut img = base.to_vec();
|
||||
for i in 0..w * h {
|
||||
let cover = (0.5 - sd[i]).clamp(0.0, 1.0);
|
||||
let halo = if sd[i] > 0.0 { 0.08 * (-sd[i] * sd[i] / 2.9).exp() } else { 0.0 };
|
||||
for (c, metal) in [0.9f32, 0.85, 0.7].into_iter().enumerate() {
|
||||
let v = &mut img[i * 3 + c];
|
||||
*v = *v * (1.0 - cover) + metal * cover + halo * (1.0 - cover);
|
||||
}
|
||||
}
|
||||
img
|
||||
};
|
||||
let clean = draw(&bg, &straight);
|
||||
let both: Vec<f32> = coils.iter().zip(&straight).map(|(a, b)| a.min(*b)).collect();
|
||||
let img = draw(&bg, &both);
|
||||
let hole = coils.iter().map(|d| *d <= margin).collect();
|
||||
(img, clean, hole)
|
||||
}
|
||||
|
||||
/// RMS over the hole of the error averaged over 7 × 7 pixels (in levels of 255): the noise
|
||||
/// cancels out, a ghost of the removed outline or a blotch of another shade does not.
|
||||
fn blotch_error(w: usize, h: usize, out: &[f32], clean: &[f32], hole: &[bool]) -> f32 {
|
||||
let (mut sum, mut n) = (0.0f64, 0usize);
|
||||
for y in 3..h - 3 {
|
||||
for x in 3..w - 3 {
|
||||
if !hole[y * w + x] {
|
||||
continue;
|
||||
}
|
||||
let mut e = [0.0f32; 3];
|
||||
for j in (y - 3..=y + 3).flat_map(|yy| (x - 3..=x + 3).map(move |xx| yy * w + xx)) {
|
||||
for (c, ec) in e.iter_mut().enumerate() {
|
||||
*ec += out[j * 3 + c] - clean[j * 3 + c];
|
||||
}
|
||||
}
|
||||
sum += f64::from(e.iter().map(|v| (v / 49.0).powi(2)).sum::<f32>() / 3.0);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
((sum / n.max(1) as f64).sqrt() * 255.0) as f32
|
||||
}
|
||||
|
||||
/// Removing thin, long objects (guitar strings, wires) from a soft background (#1366): the
|
||||
/// fill is the background, with no ghost of their outline and nothing of their colour copied
|
||||
/// back in. Their selection is long but only a few pixels thick, so the fill is worked out at
|
||||
/// a scale where it is still a few pixels thick rather than one where the strings' surroundings
|
||||
/// have merged into a blur.
|
||||
#[test]
|
||||
fn thin_objects_leave_no_ghost_and_none_of_their_colour() {
|
||||
let (w, h) = (360, 270);
|
||||
let (img, clean, hole) = strings_scene(w, h, 2.0);
|
||||
let source = auto_sampling(w, h, 3, &img, &hole);
|
||||
let out = fill(w, h, 3, &img, &hole, &source, &FillOptions::default());
|
||||
let blotch = blotch_error(w, h, &out, &clean, &hole);
|
||||
let luma = |v: &[f32], i: usize| (v[i * 3] + v[i * 3 + 1] + v[i * 3 + 2]) / 3.0;
|
||||
let bright = (0..w * h).filter(|&i| hole[i] && luma(&out, i) > luma(&clean, i) + 0.06).count();
|
||||
// Before #1366 the outlines stayed as darker and lighter blotches (13 levels of 255) and
|
||||
// 1121 pixels took the strings' colour.
|
||||
assert!(blotch < 2.5, "ghosts and blotches: {blotch:.2} levels of 255");
|
||||
assert_eq!(bright, 0, "filled pixels with the strings' colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_geometry() {
|
||||
let img = vec![0.5f32; 10 * 6];
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@
|
|||
//! source (the usual case), the vote copies the source pixels exactly; only along the seams
|
||||
//! between copied pieces does it blend.
|
||||
//!
|
||||
//! A thin hole (a selected wire, string or hair: long, but only a few patches thick) is
|
||||
//! measured by its thickness, not its bounds (#1366): the pyramid stops where it is still about
|
||||
//! two patches thick. That level is big, so the onion peel starts it only where its exhaustive
|
||||
//! search stays affordable; otherwise a membrane across the hole does, which over so short a
|
||||
//! distance is the surroundings themselves. Shrunk to where its bounds are four patches across,
|
||||
//! a long thin hole swallows its surroundings, the matches found there copy whatever happens to
|
||||
//! look like the blur (a dark blotch, or a piece of another string), and the finer levels,
|
||||
//! which only refine those matches, keep them: the removed object's outline stays visible.
|
||||
//!
|
||||
//! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4
|
||||
//! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel
|
||||
//! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour
|
||||
|
|
@ -278,6 +287,38 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize,
|
|||
b
|
||||
}
|
||||
|
||||
/// A hole at most `1 / THIN_RATIO` as thick as its bounds are long counts as thin (a wire, a
|
||||
/// string, a hair, a long brush stroke): see the module docs.
|
||||
const THIN_RATIO: usize = 4;
|
||||
|
||||
/// How thick the hole within bounds `hb` of a `w`-wide role grid is: twice the largest distance
|
||||
/// from a hole pixel to the nearest pixel that is not one (a long, thin hole is thin however long
|
||||
/// its bounds).
|
||||
fn hole_thickness(w: usize, hb: Bounds, px: &[Px]) -> usize {
|
||||
// The bounds and a ring of non-hole cells around them (also past the grid's edge).
|
||||
let (gw, gh) = (hb.width() + 2, hb.height() + 2);
|
||||
let outside: Vec<bool> = par::map(gw * gh, gw * gh >= PAR_MIN, |i| {
|
||||
let (gx, gy) = (i % gw, i / gw);
|
||||
if gx == 0 || gy == 0 || gx > hb.width() || gy > hb.height() {
|
||||
return true;
|
||||
}
|
||||
px.get((hb.y0 + gy - 1) * w + hb.x0 + gx - 1) != Some(&Px::Hole)
|
||||
});
|
||||
let deepest = crate::selection::edt(&outside, gw, gh).into_iter().fold(0.0f32, f32::max);
|
||||
(2.0 * deepest).ceil() as usize
|
||||
}
|
||||
|
||||
/// Most target pixels × sources the onion peel of a thin hole may compare (its search is
|
||||
/// exhaustive, though most comparisons stop early: about a second at this many).
|
||||
const PEEL_BUDGET: usize = 1 << 28;
|
||||
|
||||
/// Whether the onion peel of the hole cells within `hb` of a `w`-wide role grid from `sources`
|
||||
/// source centres stays within [`PEEL_BUDGET`].
|
||||
fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
|
||||
let holes: usize = (hb.y0..hb.y1).map(|y| px.get(y * w + hb.x0..y * w + hb.x1).map_or(0, |row| row.iter().filter(|k| **k == Px::Hole).count())).sum();
|
||||
holes.saturating_mul(sources) <= PEEL_BUDGET
|
||||
}
|
||||
|
||||
/// Bounds of the hole pixels of a role grid.
|
||||
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
|
||||
// Per row the first and last hole column; the union of the rows' spans.
|
||||
|
|
@ -1575,14 +1616,20 @@ fn complete_cow<'a>(
|
|||
let r = p.patch_radius.max(1);
|
||||
let psz = 2 * r + 1;
|
||||
// Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the
|
||||
// structure where the texture has averaged out, and fills flat).
|
||||
// structure where the texture has averaged out, and fills flat). A thin hole stops while it
|
||||
// is still about two patches thick: through it, not along it, is what counts (#1366).
|
||||
let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }];
|
||||
let mut boxes = vec![hb0];
|
||||
let mut ext = hb0.width().max(hb0.height());
|
||||
let thickness = hole_thickness(w, hb0, px);
|
||||
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
|
||||
if thin {
|
||||
ext = thickness;
|
||||
}
|
||||
let mut exts = vec![ext];
|
||||
while let Some(l) = levels.last() {
|
||||
ctl.check()?;
|
||||
if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
break;
|
||||
}
|
||||
let d = downsample(l, ch);
|
||||
|
|
@ -1638,9 +1685,21 @@ fn complete_cow<'a>(
|
|||
}
|
||||
}
|
||||
}
|
||||
None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?,
|
||||
// A thin hole, when peeling it would take too long, takes the membrane below: a few
|
||||
// patches thick here, a membrane across it is its surroundings.
|
||||
None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => {
|
||||
onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?
|
||||
}
|
||||
None => {
|
||||
let unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
|
||||
let mut unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
|
||||
// Across a thin hole the membrane starts a patch radius out, past the
|
||||
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
|
||||
if thin {
|
||||
let hole: Vec<bool> = lpx.iter().map(|k| *k == Px::Hole).collect();
|
||||
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) {
|
||||
*u |= d <= r as f32;
|
||||
}
|
||||
}
|
||||
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
|
||||
for (i, k) in lpx.iter().enumerate() {
|
||||
if *k == Px::Hole {
|
||||
|
|
@ -2114,6 +2173,43 @@ mod tests {
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_is_as_thick_as_it_is_through_not_along() {
|
||||
let (w, h) = (240, 120);
|
||||
let all = Bounds { x0: 0, y0: 0, x1: w, y1: h };
|
||||
let thickness = |hole: &[bool]| {
|
||||
let px: Vec<Px> = hole.iter().map(|&o| if o { Px::Hole } else { Px::Source }).collect();
|
||||
hole_thickness(w, hole_bounds(w, h, &px).unwrap_or(all), &px)
|
||||
};
|
||||
// A band 200 px long and 9 px thick; a disc of radius 20; the band touching the edge
|
||||
// counts the outside of the image as not hole.
|
||||
let band: Vec<bool> = (0..w * h).map(|i| (20..220).contains(&(i % w)) && (50..59).contains(&(i / w))).collect();
|
||||
assert!((9..=11).contains(&thickness(&band)), "{}", thickness(&band));
|
||||
assert!((39..=42).contains(&thickness(&disc(w, h, 120.0, 60.0, 20.0))));
|
||||
let edge: Vec<bool> = (0..w * h).map(|i| i / w < 9).collect();
|
||||
assert!((9..=11).contains(&thickness(&edge)), "{}", thickness(&edge));
|
||||
}
|
||||
|
||||
/// A long thin hole goes the thin way (a shallow pyramid that starts from a membrane): it is
|
||||
/// filled, only it changes, and the result does not depend on the thread count either.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn thin_holes_are_filled_the_same_on_any_number_of_threads() {
|
||||
let (w, h) = (300, 200);
|
||||
let img = busy(w, h, 3, 5);
|
||||
// A wavy line 6 px thick across the image.
|
||||
let hole: Vec<bool> = (0..w * h).map(|i| (((i % w) as f32 * 0.05).sin() * 30.0 + 100.0 - (i / w) as f32).abs() < 3.0).collect();
|
||||
let px = roles(w, h, &hole);
|
||||
let run = |threads: usize| {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
|
||||
pool.install(|| complete_masked(w, h, 3, &img, &px, &CompleteParams::default(), &photocraft_raster::Interrupt::NONE)).unwrap().unwrap()
|
||||
};
|
||||
let one = run(1);
|
||||
assert_eq!(bits(&one), bits(&run(7)));
|
||||
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| one[i * 3 + c].to_bits() == img[i * 3 + c].to_bits())));
|
||||
assert!((0..w * h).filter(|&i| hole[i]).all(|i| (0..3).all(|c| one[i * 3 + c].is_finite())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn results_do_not_depend_on_the_thread_count() {
|
||||
|
|
|
|||
|
|
@ -448,6 +448,51 @@ pub fn clean_mask(mask: &[bool], w: usize, h: usize, frac: f32, hole_frac: f32)
|
|||
out
|
||||
}
|
||||
|
||||
/// Fills the holes of `mask` (4-connected background areas not touching the border) by what
|
||||
/// they look like: a hole up to `speck_frac` of the mask's area always fills; a larger one, up
|
||||
/// to `hole_frac`, only when its pixels fit the foreground model `fg` better than `bg` on
|
||||
/// average, so an enclosed patch of backdrop stays open.
|
||||
pub fn fill_holes_by_model(mask: &[bool], img: &RgbImage, fg: &Gmm, bg: &Gmm, speck_frac: f32, hole_frac: f32) -> Vec<bool> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
let mut out = mask.to_vec();
|
||||
let area = out.iter().filter(|v| **v).count();
|
||||
let max_speck = (area as f32 * speck_frac) as usize;
|
||||
let max_hole = (area as f32 * hole_frac) as usize;
|
||||
let inv: Vec<bool> = out.iter().map(|v| !v).collect();
|
||||
let (hl, hs) = components4(&inv, w, h);
|
||||
let mut touches = vec![false; hs.len()];
|
||||
for x in 0..w {
|
||||
touches[hl[x] as usize] = true;
|
||||
touches[hl[(h - 1) * w + x] as usize] = true;
|
||||
}
|
||||
for y in 0..h {
|
||||
touches[hl[y * w] as usize] = true;
|
||||
touches[hl[y * w + w - 1] as usize] = true;
|
||||
}
|
||||
// Summed data costs per hole; only holes small enough to be candidates are evaluated.
|
||||
let mut cost = vec![(0.0f64, 0.0f64); hs.len()];
|
||||
for (i, l) in hl.iter().enumerate() {
|
||||
let l = *l as usize;
|
||||
if l == 0 || touches[l] || hs[l] > max_hole || hs[l] <= max_speck {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = img.px.get(i) {
|
||||
cost[l].0 += f64::from(fg.neg_log(*p));
|
||||
cost[l].1 += f64::from(bg.neg_log(*p));
|
||||
}
|
||||
}
|
||||
for (o, l) in out.iter_mut().zip(&hl) {
|
||||
let l = *l as usize;
|
||||
if l == 0 || touches[l] || hs[l] > max_hole {
|
||||
continue;
|
||||
}
|
||||
if hs[l] <= max_speck || cost[l].0 <= cost[l].1 {
|
||||
*o = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn components4(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
|
||||
let mut lab = vec![0u32; w * h];
|
||||
let mut sizes = vec![0usize];
|
||||
|
|
|
|||
|
|
@ -13,8 +13,17 @@
|
|||
//! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the
|
||||
//! lightly blurred CIELAB image from its mean colour.
|
||||
//! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels
|
||||
//! are definite background); the result is cleaned (small islands dropped, small holes
|
||||
//! filled) and re-cut at full resolution.
|
||||
//! are definite background); the result is cleaned (small islands dropped).
|
||||
//! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted
|
||||
//! to the image's frame marks every pixel it explains as probable background, and the rest
|
||||
//! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground.
|
||||
//! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white
|
||||
//! wall) have low saliency and in step 4 land in the background model, which then explains
|
||||
//! them away; here they start as foreground and the cut keeps them (#2788). Only what
|
||||
//! touches the first pass's result survives, so the pass cannot pull in unrelated things.
|
||||
//! 6. Holes are filled only when their colour fits the foreground model better than the
|
||||
//! background's, so a patch of backdrop enclosed by the subject (between an arm and the
|
||||
//! torso) stays open; specks still fill. Then the mask is re-cut at full resolution.
|
||||
//!
|
||||
//! Works well for a distinct object on a comparatively plain background; it is not a semantic
|
||||
//! segmenter.
|
||||
|
|
@ -25,6 +34,7 @@ use std::collections::BinaryHeap;
|
|||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Cancelled, Interrupt};
|
||||
|
||||
use super::gmm::Gmm;
|
||||
use super::grabcut::{self, BG, PR_BG, PR_FG};
|
||||
use super::slic::{rgb_to_lab, slic_lab};
|
||||
use super::{Region, Sampler};
|
||||
|
|
@ -72,13 +82,65 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt)
|
|||
}
|
||||
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
|
||||
let low: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
|
||||
let low = super::clean_mask(&low, w, h, 0.2, 0.05);
|
||||
let low = super::clean_mask(&low, w, h, 0.2, 0.0);
|
||||
if !low.iter().any(|v| *v) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (low, fg, bg) = backdrop_pass(&img, &low, ctl)?.unwrap_or((low, fg, bg));
|
||||
ctl.check()?;
|
||||
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.002, 0.05);
|
||||
super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl)
|
||||
}
|
||||
|
||||
/// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model
|
||||
/// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the
|
||||
/// subject runs out of the picture); the frame is definite background, every other pixel it
|
||||
/// explains well is probable background, and the rest (the blob, and anything the backdrop
|
||||
/// cannot account for, such as a cream shirt on a near-white wall) starts as probable
|
||||
/// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that
|
||||
/// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass.
|
||||
fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result<Option<(Vec<bool>, Gmm, Gmm)>, Cancelled> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
|
||||
for (i, on) in blob.iter().enumerate() {
|
||||
if *on {
|
||||
let (x, y) = (i % w, i / w);
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
}
|
||||
}
|
||||
if x1 < x0 || y1 < y0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4);
|
||||
let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin));
|
||||
let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1));
|
||||
let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2);
|
||||
let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h;
|
||||
let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y);
|
||||
let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect();
|
||||
let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) };
|
||||
// What the frame itself costs under its own model bounds "explained well".
|
||||
let mut frame_costs: Vec<f32> = samples.iter().map(|p| backdrop.neg_log(*p)).collect();
|
||||
frame_costs.sort_by(|a, b| a.total_cmp(b));
|
||||
let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) };
|
||||
let mut trimap: Vec<u8> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
if in_frame(x, y) && !near_blob(x, y) {
|
||||
BG
|
||||
} else if blob[i] || backdrop.neg_log(img.px[i]) > limit {
|
||||
PR_FG
|
||||
} else {
|
||||
PR_BG
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let Some((fg, bg)) = grabcut::grabcut_with(img, &mut trimap, 4, ctl)? else { return Ok(None) };
|
||||
let cut: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
|
||||
let low = super::keep_seeded(&cut, blob, w, h);
|
||||
Ok(low.iter().any(|v| *v).then_some((low, fg, bg)))
|
||||
}
|
||||
|
||||
/// Per-pixel saliency in `0..=1` (see the module docs).
|
||||
pub fn saliency(img: &super::RgbImage) -> Vec<f32> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
|
|
|
|||
|
|
@ -761,3 +761,154 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() {
|
|||
assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none());
|
||||
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
|
||||
}
|
||||
|
||||
// ---------- Select Subject on the cases reported in #2788 ----------
|
||||
|
||||
/// A figure whose upper part is close to the background colour (a cream shirt on a near-white
|
||||
/// backdrop): the whole figure is the subject. Returns (image, truth, the low-contrast part).
|
||||
pub(crate) fn low_contrast_figure(seed: u64) -> (RgbImage, Vec<bool>, Vec<bool>) {
|
||||
let (w, h) = (240usize, 200usize);
|
||||
let mut rng = Rng::new(seed);
|
||||
let mut img = RgbImage::new(w, h);
|
||||
let mut truth = vec![false; w * h];
|
||||
let mut cream = vec![false; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let body = (90..150).contains(&x) && (70..190).contains(&y);
|
||||
let shoulders = (80..160).contains(&x) && (40..70).contains(&y);
|
||||
let print = (105..135).contains(&x) && (100..130).contains(&y);
|
||||
let n = 0.012 * rng.normal();
|
||||
let i = y * w + x;
|
||||
truth[i] = body || shoulders;
|
||||
cream[i] = shoulders;
|
||||
img.px[i] = if print {
|
||||
[0.9 + n, 0.15 + n, 0.1 + n]
|
||||
} else if body {
|
||||
[0.12 + n, 0.2 + n, 0.45 + n]
|
||||
} else if shoulders {
|
||||
// Cream, a few levels off the backdrop.
|
||||
[0.90 + n, 0.88 + n, 0.80 + n]
|
||||
} else {
|
||||
[0.96 + n, 0.96 + n, 0.95 + n]
|
||||
}
|
||||
.map(|v| v.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
(img, truth, cream)
|
||||
}
|
||||
|
||||
fn coverage(region: &Region, part: &[bool], w: usize, h: usize) -> f32 {
|
||||
let (mut hit, mut n) = (0usize, 0usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if part[y * w + x] {
|
||||
n += 1;
|
||||
hit += (region.at(x as i32, y as i32) >= 0.5) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
hit as f32 / n.max(1) as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_subject_keeps_parts_close_to_the_background_colour() {
|
||||
let (img, truth, cream) = low_contrast_figure(7);
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap();
|
||||
let v = iou(®, &truth, 240, 200);
|
||||
let c = coverage(®, &cream, 240, 200);
|
||||
assert!(c > 0.85, "the cream shoulders are part of the subject: covered {c}");
|
||||
assert!(v > 0.9, "IoU {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_subject_leaves_a_background_coloured_gap_open() {
|
||||
// A dark ring (arms on hips) around a patch of plain backdrop: the patch is not the subject.
|
||||
let (w, h) = (220usize, 200usize);
|
||||
let mut rng = Rng::new(11);
|
||||
let mut img = RgbImage::new(w, h);
|
||||
let mut truth = vec![false; w * h];
|
||||
let mut gap = vec![false; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let d = (x as f32 - 110.0).hypot(y as f32 - 100.0);
|
||||
let ring = (17.0..80.0).contains(&d);
|
||||
let hole = d < 17.0;
|
||||
let n = 0.012 * rng.normal();
|
||||
let i = y * w + x;
|
||||
truth[i] = ring;
|
||||
gap[i] = hole;
|
||||
img.px[i] = if ring { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
|
||||
let g = coverage(®, &gap, w, h);
|
||||
assert!(g < 0.1, "the backdrop inside the ring stays unselected: covered {g}");
|
||||
let v = iou(®, &truth, w, h);
|
||||
assert!(v > 0.9, "IoU {v}");
|
||||
}
|
||||
|
||||
/// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`).
|
||||
#[test]
|
||||
#[ignore = "timing only"]
|
||||
fn select_subject_timing() {
|
||||
let (w, h) = (3000usize, 2000usize);
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let body = (1100..1900).contains(&x) && (700..1900).contains(&y);
|
||||
let shoulders = (1000..2000).contains(&x) && (400..700).contains(&y);
|
||||
let t = ((x * 7 + y * 13) % 17) as f32 / 1700.0;
|
||||
if body {
|
||||
[0.12 + t, 0.2, 0.45]
|
||||
} else if shoulders {
|
||||
[0.90, 0.88 + t, 0.80]
|
||||
} else {
|
||||
[0.96, 0.96 + t, 0.95]
|
||||
}
|
||||
});
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
for _ in 0..2 {
|
||||
let t0 = std::time::Instant::now();
|
||||
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
|
||||
eprintln!("select_subject 6 MP: {:?} bbox {:?}", t0.elapsed(), reg.bbox);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the #2788 scenes and the Select Subject mask over them as PPM files into
|
||||
/// `$SUBJECT_DUMP` (for the PR's before/after pictures).
|
||||
#[test]
|
||||
#[ignore = "writes pictures"]
|
||||
fn select_subject_dump_pictures() {
|
||||
let Ok(dir) = std::env::var("SUBJECT_DUMP") else { return };
|
||||
let write = |name: &str, img: &RgbImage, reg: Option<&Region>| {
|
||||
let mut out = format!("P6\n{} {}\n255\n", img.w, img.h).into_bytes();
|
||||
for y in 0..img.h {
|
||||
for x in 0..img.w {
|
||||
let mut c = img.at(x, y);
|
||||
if let Some(r) = reg
|
||||
&& r.at(x as i32, y as i32) >= 0.5
|
||||
{
|
||||
c = [c[0] * 0.5 + 0.5, c[1] * 0.5, c[2] * 0.5 + 0.5];
|
||||
}
|
||||
out.extend(c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||
}
|
||||
}
|
||||
std::fs::write(format!("{dir}/{name}.ppm"), out).unwrap();
|
||||
};
|
||||
let (img, _, _) = low_contrast_figure(7);
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200));
|
||||
write("figure", &img, None);
|
||||
write("figure-selected", &img, reg.as_ref());
|
||||
let (w, h) = (220usize, 200usize);
|
||||
let mut rng = Rng::new(11);
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let d = (x as f32 - 110.0).hypot(y as f32 - 100.0);
|
||||
let n = 0.012 * rng.normal();
|
||||
if (17.0..80.0).contains(&d) { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0))
|
||||
});
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32));
|
||||
write("ring", &img, None);
|
||||
write("ring-selected", &img, reg.as_ref());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ fn warp(
|
|||
// that matters is the one at the source point landing in the clip's centre.
|
||||
let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) {
|
||||
Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0),
|
||||
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0),
|
||||
None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0),
|
||||
};
|
||||
let scale = h.local_scale(cx, cy);
|
||||
let reduced;
|
||||
|
|
@ -366,10 +366,10 @@ fn warp(
|
|||
return Ok(None);
|
||||
}
|
||||
let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect();
|
||||
let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3;
|
||||
let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3;
|
||||
let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3;
|
||||
let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3;
|
||||
let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
|
||||
let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
|
||||
let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
|
||||
let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
|
||||
// Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint.
|
||||
let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0);
|
||||
let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) };
|
||||
|
|
@ -392,6 +392,15 @@ fn warp(
|
|||
px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64
|
||||
}
|
||||
};
|
||||
// Samples past the source rect repeat its edge, so a stretched edge row or column keeps its
|
||||
// colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage
|
||||
// instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels.
|
||||
let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c);
|
||||
let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0);
|
||||
let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0));
|
||||
let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 };
|
||||
let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs());
|
||||
let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs());
|
||||
let w = t.width() as usize;
|
||||
let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
|
||||
let bytes = len.checked_mul(std::mem::size_of::<f32>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
|
||||
|
|
@ -424,7 +433,7 @@ fn warp(
|
|||
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
|
||||
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
|
||||
for (c, s) in acc.iter_mut().enumerate().take(n) {
|
||||
*s += at(ix + dx, iy + dy, c) * wx * wy;
|
||||
*s += clamped(ix + dx, iy + dy, c) * wx * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -436,12 +445,21 @@ fn warp(
|
|||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let k = wxi * wyj;
|
||||
for (c, s) in acc.iter_mut().enumerate().take(n) {
|
||||
*s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
|
||||
*s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if interp != Interp::Nearest {
|
||||
// How much of this pixel the warped source rect covers, per axis.
|
||||
let (uc, vc) = (u + 0.5, v + 0.5);
|
||||
let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0);
|
||||
let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0);
|
||||
for s in acc.iter_mut().take(n) {
|
||||
*s *= cu * cv;
|
||||
}
|
||||
}
|
||||
let al = acc[a].clamp(0.0, 1.0);
|
||||
if al <= 0.0 {
|
||||
continue;
|
||||
|
|
@ -539,6 +557,24 @@ mod tests {
|
|||
Surface::new(PixelFormat::RGBA8)
|
||||
}
|
||||
|
||||
/// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in
|
||||
/// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased.
|
||||
#[test]
|
||||
fn stretched_single_column_stays_opaque() {
|
||||
let mut s = rgba();
|
||||
s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]);
|
||||
for interp in [Interp::Bicubic, Interp::Bilinear] {
|
||||
let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap();
|
||||
let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap();
|
||||
assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}");
|
||||
for x in [0, 1, 25, 50, 75, 99] {
|
||||
for y in 0..4 {
|
||||
assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left
|
||||
/// half is grey 64 and right half grey 192: the row across the edge, columns 26..36.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@
|
|||
//! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each
|
||||
//! cell's two triangles are rasterized in destination space with the source position
|
||||
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
|
||||
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so
|
||||
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
|
||||
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few pixels across in
|
||||
//! both the source and the destination (a thin layer stretched far gets as many cells as its
|
||||
//! warped size needs), so the piecewise-affine inverse is within a small fraction of a pixel of
|
||||
//! the true one; affine
|
||||
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
|
||||
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
|
||||
//! source pixel), so thin lines don't alias. Where the warp folds over itself the
|
||||
|
|
@ -77,8 +79,11 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
|
|||
return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
|
||||
}
|
||||
let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height()));
|
||||
let nx = ((sw / CELL).ceil() as usize).clamp(1, MAX_CELLS);
|
||||
let ny = ((sh / CELL).ceil() as usize).clamp(1, MAX_CELLS);
|
||||
// Cells are a few pixels across on both sides of the map: a thin source stretched far (a
|
||||
// 3 px column scaled into a wide band, then bent) needs many cells, or it comes out straight.
|
||||
let (lu, lv) = mapped_lengths(src_rect, f);
|
||||
let cells = |s: f64, d: f64| ((s.max(d) / CELL).ceil() as usize).clamp(1, MAX_CELLS);
|
||||
let (nx, ny) = (cells(sw, lu), cells(sh, lv));
|
||||
// Forward-mapped vertices (and their source positions).
|
||||
let mut verts = Vec::with_capacity((nx + 1) * (ny + 1));
|
||||
for j in 0..=ny {
|
||||
|
|
@ -101,6 +106,43 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
|
|||
warp_triangles(src, src_rect, &verts, &tris, interp)
|
||||
}
|
||||
|
||||
/// Probe lines per axis [`mapped_lengths`] follows through the map.
|
||||
const PROBE: usize = 16;
|
||||
|
||||
/// The longest destination length of a source row and of a source column under `f`, measured
|
||||
/// along polylines through a [`PROBE`]-step grid. Non-finite steps count as zero.
|
||||
fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -> (f64, f64) {
|
||||
let (x0, y0) = (f64::from(src_rect.x0), f64::from(src_rect.y0));
|
||||
let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height()));
|
||||
let n = PROBE + 1;
|
||||
let mut pts = Vec::with_capacity(n * n);
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let (dx, dy) = f(x0 + sw * i as f64 / PROBE as f64, y0 + sh * j as f64 / PROBE as f64);
|
||||
pts.push([dx, dy]);
|
||||
}
|
||||
}
|
||||
let step = |a: [f64; 2], b: [f64; 2]| {
|
||||
let d = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
if d.is_finite() { d } else { 0.0 }
|
||||
};
|
||||
let (mut lu, mut lv) = (0.0f64, 0.0f64);
|
||||
for k in 0..n {
|
||||
let (mut row, mut col) = (0.0, 0.0);
|
||||
for m in 1..n {
|
||||
if let (Some(&a), Some(&b)) = (pts.get(k * n + m - 1), pts.get(k * n + m)) {
|
||||
row += step(a, b);
|
||||
}
|
||||
if let (Some(&a), Some(&b)) = (pts.get((m - 1) * n + k), pts.get(m * n + k)) {
|
||||
col += step(a, b);
|
||||
}
|
||||
}
|
||||
lu = lu.max(row);
|
||||
lv = lv.max(col);
|
||||
}
|
||||
(lu, lv)
|
||||
}
|
||||
|
||||
/// Rasterizes textured triangles: each vertex is `(destination, source)` in document pixels and
|
||||
/// each triangle samples `src` (restricted to `src_rect`) through the barycentric
|
||||
/// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the
|
||||
|
|
@ -399,6 +441,28 @@ mod tests {
|
|||
surf
|
||||
}
|
||||
|
||||
#[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
|
||||
// source pixels (one cell across), so the arch came out as a straight band.
|
||||
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
|
||||
src.fill_rect(Rect::new(0, 0, 3, 40), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let arch = |x: f64, y: f64| {
|
||||
let dx = x * 100.0;
|
||||
let t = (dx - 150.0) / 150.0;
|
||||
(dx, y + 40.0 * (1.0 - t * t))
|
||||
};
|
||||
let out = warp_mesh_surface(&src, Rect::new(0, 0, 3, 40), &arch, Interp::Bilinear);
|
||||
let alpha = |x: i32, y: i32| {
|
||||
let mut p = [0.0f32; 4];
|
||||
out.read_pixel(x, y, &mut p);
|
||||
p[3]
|
||||
};
|
||||
// The band follows the arch: 40 px lower in the middle, about 36 px a third of the way in.
|
||||
assert!(alpha(150, 60) > 0.99 && alpha(150, 20) < 0.01, "mid {} {}", alpha(150, 60), alpha(150, 20));
|
||||
assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() {
|
||||
// #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about
|
||||
|
|
|
|||
59
crates/algo/tests/warp_clipped.rs
Normal file
59
crates/algo/tests/warp_clipped.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
#[test]
|
||||
fn clipped_warp_matches_the_existing_kernel_at_every_depth() {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
|
||||
source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]);
|
||||
source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]);
|
||||
let clip = Rect::new(10, 10, 52, 52);
|
||||
for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
|
||||
for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] {
|
||||
let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap();
|
||||
let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap();
|
||||
assert_eq!(full.read_region(clip), bounded.read_region(clip));
|
||||
assert!(clip.contains_rect(&bounded.content_bounds()));
|
||||
assert!(bounded.tiles().count() <= clip.tiles().count());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_scaled_warp_allocates_only_the_destination_clip() {
|
||||
let mut source = Surface::new(PixelFormat::RGBA8);
|
||||
source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]);
|
||||
let clip = Rect::new(0, 0, 64, 64);
|
||||
let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]);
|
||||
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap();
|
||||
assert!(clip.contains_rect(&output.content_bounds()));
|
||||
assert!(output.tiles().count() <= clip.tiles().count());
|
||||
assert_eq!(output.pixel(32, 32), source.pixel(10, 10));
|
||||
assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_footprint_handles_extreme_finite_affines() {
|
||||
let mut source = Surface::new(PixelFormat::RGBA8);
|
||||
source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]);
|
||||
// A strongly skewed inverse maps a small visible tile beyond i32 limits.
|
||||
let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
let clip = Rect::new(0, 0, 32, 32);
|
||||
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap();
|
||||
assert!(clip.contains_rect(&output.content_bounds()));
|
||||
assert!(output.tiles().count() <= clip.tiles().count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_center_does_not_add_extreme_coordinates_in_i32() {
|
||||
let mut source = Surface::new(PixelFormat::RGBA8);
|
||||
let origin = 1_500_000_000;
|
||||
let bounds = Rect::new(origin, origin, origin + 4, origin + 4);
|
||||
source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]);
|
||||
let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]);
|
||||
let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap();
|
||||
assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2));
|
||||
}
|
||||
|
|
@ -29,8 +29,8 @@ use serde_json::{Value, json};
|
|||
|
||||
use crate::budgets::{BatchReplyBudget, write_reply};
|
||||
use crate::security::{
|
||||
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
|
||||
read_bounded_line, repeat_auth_reply,
|
||||
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply,
|
||||
configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply,
|
||||
};
|
||||
use crate::{AutomationError, Headless};
|
||||
|
||||
|
|
@ -91,17 +91,9 @@ impl Headless {
|
|||
let all = self.command_list();
|
||||
Ok(match str_of(&p, "filter").map(str::to_lowercase) {
|
||||
None => all,
|
||||
Some(n) => Value::Array(
|
||||
all.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| {
|
||||
let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""));
|
||||
hay.to_lowercase().contains(&n)
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
),
|
||||
Some(n) => {
|
||||
Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect())
|
||||
}
|
||||
})
|
||||
}
|
||||
"session.list" => Ok(self.session_list()),
|
||||
|
|
@ -270,10 +262,12 @@ pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write)
|
|||
}
|
||||
}
|
||||
|
||||
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> {
|
||||
/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however
|
||||
/// much it sends meanwhile (#4965); after that only the idle timeout applies.
|
||||
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> {
|
||||
configure_stream(&stream)?;
|
||||
let read = stream.try_clone()?;
|
||||
let mut reader = std::io::BufReader::new(read);
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
|
||||
let mut out = stream;
|
||||
let mut line = String::new();
|
||||
let mut authenticated = false;
|
||||
|
|
@ -298,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
|
|||
} else {
|
||||
let (reply, ok) = authentication_reply(&line, token);
|
||||
authenticated = ok;
|
||||
if ok {
|
||||
reader.get_mut().authenticated()?;
|
||||
}
|
||||
reply
|
||||
};
|
||||
write_reply(&mut out, &reply)?;
|
||||
|
|
@ -330,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl
|
|||
let token = Arc::clone(&token);
|
||||
std::thread::spawn(move || {
|
||||
let _permit = permit;
|
||||
let _ = serve_tcp_connection(&h, stream, &token);
|
||||
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -365,7 +362,7 @@ mod tests {
|
|||
|
||||
/// Pins the reply shape documented in docs/control-protocol.md › Command list replies
|
||||
/// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with
|
||||
/// exactly these keys, from `engine.commands` (filtered or not) and `command.list`.
|
||||
/// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`.
|
||||
#[test]
|
||||
fn command_list_replies_are_documented_array_of_objects() {
|
||||
let h = session();
|
||||
|
|
@ -377,8 +374,10 @@ mod tests {
|
|||
let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}"));
|
||||
assert!(!items.is_empty(), "{name}");
|
||||
for c in items {
|
||||
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
// `keywords` is optional: only commands with other search names carry it (#5060).
|
||||
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect();
|
||||
keys.sort_unstable();
|
||||
assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}");
|
||||
assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}");
|
||||
assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}");
|
||||
assert!(c["needsDocument"].is_boolean(), "{c}");
|
||||
|
|
@ -820,6 +819,60 @@ mod tests {
|
|||
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
|
||||
}
|
||||
|
||||
/// #4965: a connection that keeps sending without authenticating is closed at the deadline,
|
||||
/// and one that authenticated in time keeps being served after it.
|
||||
#[test]
|
||||
fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() {
|
||||
use std::io::{BufReader, Write as _};
|
||||
use std::time::{Duration, Instant};
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let h = Mutex::new(Headless::new());
|
||||
let mut results = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let started = Instant::now();
|
||||
let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300));
|
||||
results.push((r.map_err(|e| e.kind()), started.elapsed()));
|
||||
}
|
||||
results
|
||||
});
|
||||
|
||||
// Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout.
|
||||
let mut slow = std::net::TcpStream::connect(addr).unwrap();
|
||||
for _ in 0..150 {
|
||||
if slow.write_all(b" ").is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
drop(slow);
|
||||
|
||||
// Authenticates at once, then asks again 300 ms after the deadline.
|
||||
let mut s = std::net::TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
|
||||
let mut reader = BufReader::new(s.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
|
||||
std::thread::sleep(Duration::from_millis(600));
|
||||
writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], "a");
|
||||
drop(reader);
|
||||
drop(s);
|
||||
|
||||
let results = server.join().unwrap();
|
||||
let (slow_result, slow_time) = &results[0];
|
||||
assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}");
|
||||
assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}");
|
||||
assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
|
||||
use std::io::{BufReader, Write as _};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
//! Shared security primitives for the loopback JSON control transports.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16;
|
|||
pub const MAX_BATCH_STEPS: usize = 256;
|
||||
/// Idle/read and write timeout for loopback TCP connections.
|
||||
pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts
|
||||
/// again with every read, so a client that kept sending without ever completing `auth` held one
|
||||
/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965).
|
||||
pub const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
||||
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
|
||||
|
|
@ -242,6 +246,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> {
|
|||
stream.set_write_timeout(Some(IO_TIMEOUT))
|
||||
}
|
||||
|
||||
/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail
|
||||
/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime;
|
||||
/// the caller then drops the connection and its [`ConnectionPermit`].
|
||||
pub struct AuthDeadlineStream {
|
||||
stream: std::net::TcpStream,
|
||||
/// `None` once authenticated.
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AuthDeadlineStream {
|
||||
/// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]).
|
||||
pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self {
|
||||
// A limit too large to represent expires at once rather than never.
|
||||
let now = Instant::now();
|
||||
Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) }
|
||||
}
|
||||
|
||||
/// The connection authenticated: only the idle timeout applies from here on.
|
||||
pub fn authenticated(&mut self) -> std::io::Result<()> {
|
||||
self.deadline = None;
|
||||
self.stream.set_read_timeout(Some(IO_TIMEOUT))
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for AuthDeadlineStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if let Some(deadline) = self.deadline {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time"));
|
||||
}
|
||||
// Wait no longer than what is left, so a silent client is dropped at the deadline too.
|
||||
self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?;
|
||||
}
|
||||
self.stream.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
|
@ -327,6 +369,70 @@ mod tests {
|
|||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// A connected loopback pair: the accepted (server) end and the client end.
|
||||
fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (server, _) = listener.accept().unwrap();
|
||||
configure_stream(&server).unwrap();
|
||||
(server, client)
|
||||
}
|
||||
|
||||
/// #4965: activity must not extend the time a connection may stay unauthenticated.
|
||||
#[test]
|
||||
fn an_active_connection_is_dropped_at_the_authentication_deadline() {
|
||||
let (server, mut client) = tcp_pair();
|
||||
// A byte every 20 ms and never a newline: each read succeeds long before the idle timeout.
|
||||
let trickle = std::thread::spawn(move || {
|
||||
for _ in 0..150 {
|
||||
if client.write_all(b"x").is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300)));
|
||||
let mut line = String::new();
|
||||
let started = Instant::now();
|
||||
let err = read_bounded_line(&mut reader, &mut line).unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}");
|
||||
assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed());
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed());
|
||||
// It stays expired.
|
||||
assert!(read_bounded_line(&mut reader, &mut line).is_err());
|
||||
drop(reader);
|
||||
trickle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_connection_is_dropped_at_the_authentication_deadline() {
|
||||
let (server, _client) = tcp_pair();
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
|
||||
let mut line = String::new();
|
||||
let started = Instant::now();
|
||||
assert!(read_bounded_line(&mut reader, &mut line).is_err());
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_connection_outlives_the_authentication_deadline() {
|
||||
let (server, mut client) = tcp_pair();
|
||||
let sender = std::thread::spawn(move || {
|
||||
client.write_all(b"one\n").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
client.write_all(b"two\n").unwrap();
|
||||
});
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
|
||||
let mut line = String::new();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "one\n");
|
||||
reader.get_mut().authenticated().unwrap();
|
||||
// Sent 300 ms after the deadline: only the idle timeout applies now.
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "two\n");
|
||||
sender.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limiter_releases_capacity() {
|
||||
let limiter = ConnectionLimiter::new(1);
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ pub struct SelectParams {
|
|||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListParams {
|
||||
/// Only commands whose id or label contains this text (case-insensitive).
|
||||
/// Only commands whose id, label or keywords contain this text (case-insensitive).
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
/// Only commands that can run right now.
|
||||
|
|
@ -320,11 +320,16 @@ pub struct UiSetParams {
|
|||
/// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never),
|
||||
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
|
||||
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
|
||||
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
|
||||
/// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware,
|
||||
/// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit,
|
||||
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
|
||||
/// active layer's storage tiles). Other fields are an error.
|
||||
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
|
||||
/// "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.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
|
|
@ -821,9 +826,8 @@ impl PhotocraftMcp {
|
|||
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
let hay =
|
||||
format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase();
|
||||
needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
|
||||
needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))
|
||||
&& (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
|
||||
})
|
||||
.collect();
|
||||
Ok(ok_json(&Value::Array(items)))
|
||||
|
|
|
|||
|
|
@ -363,12 +363,16 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
|
|||
|
||||
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
match id {
|
||||
"actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
|
||||
"actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
|
||||
// `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" => 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())
|
||||
}
|
||||
"plugin.reload" => true,
|
||||
_ => false,
|
||||
}
|
||||
|
|
@ -712,6 +716,12 @@ mod tests {
|
|||
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
|
||||
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
|
||||
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
|
||||
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
|
||||
for key in ["importKys", "exportKys", "summarize"] {
|
||||
assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}");
|
||||
}
|
||||
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
|
||||
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
|
||||
|
|
@ -758,6 +768,7 @@ mod tests {
|
|||
fn automation_rejects_ambient_path_commands_and_preferences() {
|
||||
for (id, params) in [
|
||||
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
|
||||
("brush.presets.importImage", serde_json::json!({"path": "/outside/tip.png"})),
|
||||
("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"})),
|
||||
|
|
@ -772,6 +783,7 @@ mod tests {
|
|||
}
|
||||
for (id, params) in [
|
||||
("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})),
|
||||
("brush.presets.importImage", serde_json::json!({"data": "QUJD"})),
|
||||
("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),
|
||||
("plugin.install", serde_json::json!({"data": "QUJD"})),
|
||||
("swatches.import", serde_json::json!({"data": "QUJD"})),
|
||||
|
|
|
|||
|
|
@ -167,6 +167,9 @@ async fn command_list_filters() {
|
|||
let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await);
|
||||
assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all);
|
||||
assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string()));
|
||||
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
|
||||
let marquee = json_of(&call(&client, "command_list", json!({"filter": "ellipse"})).await);
|
||||
assert!(marquee.as_array().unwrap().iter().any(|c| c["id"] == "select.rect"), "{marquee}");
|
||||
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
|
||||
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
|
||||
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
|
||||
|
|
|
|||
259
crates/automation/tests/smart_filter_authorization.rs
Normal file
259
crates/automation/tests/smart_filter_authorization.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
std::fs::create_dir(base.join("workspace")).unwrap();
|
||||
std::fs::create_dir(base.join("outside")).unwrap();
|
||||
let f = Self(base);
|
||||
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
|
||||
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
|
||||
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
|
||||
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
|
||||
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
self.0.join("workspace")
|
||||
}
|
||||
fn outside(&self, name: &str) -> PathBuf {
|
||||
self.0.join("outside").join(name)
|
||||
}
|
||||
fn headless(&self) -> Headless {
|
||||
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
|
||||
}
|
||||
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
|
||||
let mut h = self.headless();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let (id, params) = if lookup {
|
||||
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
|
||||
} else {
|
||||
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
|
||||
};
|
||||
h.session.execute(id, params).unwrap();
|
||||
(h, layer)
|
||||
}
|
||||
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
|
||||
let (h, layer) = self.setup(lookup);
|
||||
let mut doc = (*h.session.active().unwrap().doc).clone();
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
|
||||
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
|
||||
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
|
||||
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
|
||||
(doc, layer)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
|
||||
let mut pixels = Vec::new();
|
||||
for _ in 0..32 {
|
||||
for x in 0..32 {
|
||||
pixels.extend([value(x); 3]);
|
||||
}
|
||||
}
|
||||
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
|
||||
}
|
||||
fn cube(value: u8) -> String {
|
||||
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
|
||||
}
|
||||
fn path_params(lookup: bool, path: &Path) -> Value {
|
||||
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
|
||||
}
|
||||
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
|
||||
match result {
|
||||
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
|
||||
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(lookup);
|
||||
let before = h.render_png(None, 64).unwrap();
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
let journal = h.session.journal.len();
|
||||
for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } {
|
||||
let params = path_params(lookup, &f.outside(name));
|
||||
let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" };
|
||||
assert_denied(h.session.execute(id, params.clone()));
|
||||
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}})));
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
assert_eq!(h.session.journal.len(), journal);
|
||||
assert_eq!(h.render_png(None, 64).unwrap(), before);
|
||||
}
|
||||
// Safe numeric and embedded-data edits remain usable.
|
||||
let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) };
|
||||
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_playback_cannot_hide_a_smart_filter_path_edit() {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(false);
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
h.session.actions.list.push(Action::new(
|
||||
"edit filter",
|
||||
vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))],
|
||||
));
|
||||
let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap();
|
||||
assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}");
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (_, layer) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}})));
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
// Removing an ambient input makes the filter usable again.
|
||||
let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) };
|
||||
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
for warm_cache in [false, true] {
|
||||
if warm_cache {
|
||||
let mut trusted = doc.clone();
|
||||
assert!(smart_cmds::refresh(&mut trusted, layer).unwrap());
|
||||
}
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_rerender_keeps_the_desktop_request_authority() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.authorize = Some(authorize_desktop_engine_step);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
|
||||
// The desktop restores UI authority as soon as the automation request returns.
|
||||
s.authorize = None;
|
||||
let Started::Job(job) = started else { panic!("expected a background render") };
|
||||
assert_denied(s.wait_job(job));
|
||||
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
|
||||
// The worker's restriction must not leak into subsequent trusted UI work.
|
||||
s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferring_stored_filters_cannot_read_outside_files() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (_, from) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
for copy in [false, true] {
|
||||
assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy})));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(lookup);
|
||||
let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)});
|
||||
assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png"))));
|
||||
let mut trusted = Headless::trusted_local();
|
||||
trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None);
|
||||
let mut renders = Vec::new();
|
||||
for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } {
|
||||
trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap();
|
||||
renders.push(trusted.render_png(None, 64).unwrap());
|
||||
}
|
||||
assert_ne!(renders[0], renders[1], "trusted path-based filter still works");
|
||||
assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() {
|
||||
// link-watch and transform paths re-render through Session::edit, not Session::execute or
|
||||
// jobs::run; the gate must reach them and the denial must not silently pass pixels through
|
||||
// into the process-wide stack cache.
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
// Session::edit is the outside-execute mutation path (an edit closure the UI or a
|
||||
// linked-file change runs); the stored filter must be denied there too.
|
||||
assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
// A trusted session (no gate) still renders the same document.
|
||||
let mut trusted = Session::new();
|
||||
trusted.add_document(doc, None);
|
||||
let mut d = (*trusted.active().unwrap().doc).clone();
|
||||
assert!(smart_cmds::refresh(&mut d, layer).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
|
||||
// The denial path must fail the render before any stage is cached process-wide.
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.authorize = Some(authorize_desktop_engine_step);
|
||||
let bytes = smart_cmds::stack_cache_bytes();
|
||||
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
|
||||
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# photocraft-cms
|
||||
|
||||
Pure-Rust ICC colour management for Photocraft. No workspace dependencies (L0, publishable),
|
||||
Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable),
|
||||
no C, no `unsafe`. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2)
|
||||
specifications, CIE 15, and Adobe's published black point compensation paper.
|
||||
|
||||
|
|
@ -53,13 +53,13 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
|||
| `rec2020` | Rec. 2020 primaries, Rec. 709 OETF |
|
||||
| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) |
|
||||
| `lab-d50` | Lab identity (v4 encoding) |
|
||||
| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK |
|
||||
| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK |
|
||||
|
||||
### The CMYK profile
|
||||
|
||||
Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely
|
||||
downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry
|
||||
redistribution conditions or unclear licences, so Photocraft generates its own
|
||||
redistribution conditions or unclear licences, so PhotoCraft generates its own
|
||||
(`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with
|
||||
ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY,
|
||||
17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -26,6 +26,8 @@ pub enum Builtin {
|
|||
ProPhotoCompat,
|
||||
/// sRGB primaries with a linear transfer function.
|
||||
LinearSrgb,
|
||||
/// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders.
|
||||
AcesCg,
|
||||
/// Rec. 2020 primaries, D65, Rec. 709 transfer function.
|
||||
Rec2020,
|
||||
/// Gray, gamma 2.2.
|
||||
|
|
@ -43,12 +45,13 @@ pub enum Builtin {
|
|||
}
|
||||
|
||||
impl Builtin {
|
||||
pub const ALL: [Builtin; 11] = [
|
||||
pub const ALL: [Builtin; 12] = [
|
||||
Builtin::Srgb,
|
||||
Builtin::DisplayP3,
|
||||
Builtin::AdobeRgbCompat,
|
||||
Builtin::ProPhotoCompat,
|
||||
Builtin::LinearSrgb,
|
||||
Builtin::AcesCg,
|
||||
Builtin::Rec2020,
|
||||
Builtin::GrayGamma22,
|
||||
Builtin::SGray,
|
||||
|
|
@ -65,6 +68,7 @@ impl Builtin {
|
|||
Builtin::AdobeRgbCompat => "adobe-rgb-compat",
|
||||
Builtin::ProPhotoCompat => "prophoto-compat",
|
||||
Builtin::LinearSrgb => "linear-srgb",
|
||||
Builtin::AcesCg => "aces-cg",
|
||||
Builtin::Rec2020 => "rec2020",
|
||||
Builtin::GrayGamma22 => "gray-gamma-2.2",
|
||||
Builtin::SGray => "sgray",
|
||||
|
|
@ -79,14 +83,15 @@ impl Builtin {
|
|||
match self {
|
||||
Builtin::Srgb => "sRGB IEC61966-2.1",
|
||||
Builtin::DisplayP3 => "Display P3",
|
||||
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)",
|
||||
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)",
|
||||
Builtin::LinearSrgb => "Linear sRGB (Photocraft)",
|
||||
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
|
||||
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
|
||||
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
|
||||
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)",
|
||||
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
|
||||
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)",
|
||||
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)",
|
||||
Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)",
|
||||
Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)",
|
||||
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)",
|
||||
Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)",
|
||||
Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)",
|
||||
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)",
|
||||
Builtin::LabD50 => "Lab D50 identity (PhotoCraft)",
|
||||
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +109,7 @@ impl Builtin {
|
|||
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
|
||||
"sgray" | "graysrgb" => Some(Builtin::SGray),
|
||||
"dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat),
|
||||
"aces-cg" | "acescg" => Some(Builtin::AcesCg),
|
||||
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
|
||||
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
|
||||
_ => None,
|
||||
|
|
@ -113,7 +119,7 @@ impl Builtin {
|
|||
|
||||
/// The profile (built once per process).
|
||||
pub fn profile(self) -> &'static Profile {
|
||||
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
|
||||
static CELLS: [OnceLock<Profile>; 12] = [const { OnceLock::new() }; 12];
|
||||
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
|
||||
// so the discriminant is the cell index.
|
||||
CELLS[self as usize].get_or_init(|| build(self))
|
||||
|
|
@ -140,7 +146,7 @@ pub fn srgb() -> &'static Profile {
|
|||
/// only assigned to such files, never offered as a working space.
|
||||
pub fn linear_gray() -> &'static Profile {
|
||||
static PROFILE: OnceLock<Profile> = OnceLock::new();
|
||||
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
|
||||
PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0)))
|
||||
}
|
||||
|
||||
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
|
||||
|
|
@ -256,12 +262,13 @@ fn lab() -> Profile {
|
|||
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
|
||||
|
||||
fn build(b: Builtin) -> Profile {
|
||||
let (srgb_p, p3, adobe, romm, bt2020) = (
|
||||
let (srgb_p, p3, adobe, romm, bt2020, ap1) = (
|
||||
[[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]],
|
||||
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
|
||||
[[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]],
|
||||
[[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]],
|
||||
[[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
|
||||
[[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]],
|
||||
);
|
||||
match b {
|
||||
Builtin::Srgb => rgb(b.description(), srgb_p, d65(), srgb_trc()),
|
||||
|
|
@ -269,6 +276,9 @@ fn build(b: Builtin) -> Profile {
|
|||
Builtin::AdobeRgbCompat => rgb(b.description(), adobe, d65(), Curve::Gamma(563.0 / 256.0)),
|
||||
Builtin::ProPhotoCompat => rgb(b.description(), romm, math::D50, Curve::Gamma(1.8)),
|
||||
Builtin::LinearSrgb => rgb(b.description(), srgb_p, d65(), Curve::Gamma(1.0)),
|
||||
// ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60,
|
||||
// xy 0.32168/0.33767, the ACES 2065-1 neutral).
|
||||
Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)),
|
||||
Builtin::Rec2020 => {
|
||||
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
|
||||
let a = 1.099;
|
||||
|
|
|
|||
|
|
@ -429,10 +429,18 @@ impl<'a> Reader<'a> {
|
|||
Ok(self.u32(off)? as i32 as f64 / 65536.0)
|
||||
}
|
||||
fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> {
|
||||
Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?])
|
||||
let r = Reader { b: self.slice(off, 12)? };
|
||||
Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?])
|
||||
}
|
||||
}
|
||||
|
||||
/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not
|
||||
/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32):
|
||||
/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976).
|
||||
fn extent(base: usize, n: usize, size: usize) -> Result<usize, CmsError> {
|
||||
n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated)
|
||||
}
|
||||
|
||||
fn sig(s: &[u8; 4]) -> u32 {
|
||||
u32::from_be_bytes(*s)
|
||||
}
|
||||
|
|
@ -572,14 +580,15 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
|
|||
match r.slice(0, 4)? {
|
||||
b"curv" => {
|
||||
let n = r.u32(8)? as usize;
|
||||
// Before any arithmetic on it: `n * 2` overflows a 32-bit `usize` (#4976).
|
||||
if n > 1 << 20 {
|
||||
return Err(CmsError::Invalid("curve too long".into()));
|
||||
}
|
||||
let len = 12 + n * 2;
|
||||
match n {
|
||||
0 => Ok((Curve::Identity, len)),
|
||||
1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)),
|
||||
_ => {
|
||||
if n > 1 << 20 {
|
||||
return Err(CmsError::Invalid("curve too long".into()));
|
||||
}
|
||||
let s = r.slice(12, n * 2)?;
|
||||
let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
|
||||
Ok((Curve::Table(t), len))
|
||||
|
|
@ -684,36 +693,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
|
|||
return Err(CmsError::Invalid("bad lutAB dimensions".into()));
|
||||
}
|
||||
let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize);
|
||||
let matrix = |off: usize| -> Result<Stage, CmsError> {
|
||||
let mut m = vec![0.0; 9];
|
||||
for (k, v) in m.iter_mut().enumerate() {
|
||||
*v = r.s15f16(off + k * 4)?;
|
||||
}
|
||||
let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?];
|
||||
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
|
||||
};
|
||||
let clut = |off: usize, cin: usize, cout: usize| -> Result<Stage, CmsError> {
|
||||
let mut grid = Vec::with_capacity(cin);
|
||||
for k in 0..cin {
|
||||
let g = r.u8(off + k)? as usize;
|
||||
if g < 1 {
|
||||
return Err(CmsError::Invalid("zero grid points".into()));
|
||||
}
|
||||
grid.push(g);
|
||||
}
|
||||
let prec = r.u8(off + 16)? as usize;
|
||||
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
|
||||
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
|
||||
return Err(CmsError::Invalid("CLUT too large".into()));
|
||||
}
|
||||
let total = nodes as usize * cout;
|
||||
let data: Vec<f32> = match prec {
|
||||
1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
|
||||
2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
|
||||
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
|
||||
};
|
||||
Ok(Stage::Clut(Clut::new(grid, cout, data)))
|
||||
};
|
||||
let matrix = |off: usize| lut_ab_matrix(&r, off);
|
||||
let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout);
|
||||
let mut stages = Vec::new();
|
||||
if ty == b"mAB " {
|
||||
// A curves → CLUT → M curves → matrix → B curves.
|
||||
|
|
@ -760,6 +741,45 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
|
||||
fn lut_ab_matrix(r: &Reader, off: usize) -> Result<Stage, CmsError> {
|
||||
// One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`.
|
||||
let e = Reader { b: r.slice(off, 48)? };
|
||||
let mut m = vec![0.0; 9];
|
||||
for (k, v) in m.iter_mut().enumerate() {
|
||||
*v = e.s15f16(k * 4)?;
|
||||
}
|
||||
let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?];
|
||||
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
|
||||
}
|
||||
|
||||
/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
|
||||
fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result<Stage, CmsError> {
|
||||
// Grid points (16 bytes), precision and padding; the samples follow.
|
||||
let head = Reader { b: r.slice(off, 20)? };
|
||||
let mut grid = Vec::with_capacity(cin);
|
||||
for k in 0..cin {
|
||||
let g = head.u8(k)? as usize;
|
||||
if g < 1 {
|
||||
return Err(CmsError::Invalid("zero grid points".into()));
|
||||
}
|
||||
grid.push(g);
|
||||
}
|
||||
let prec = head.u8(16)? as usize;
|
||||
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
|
||||
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
|
||||
return Err(CmsError::Invalid("CLUT too large".into()));
|
||||
}
|
||||
let total = nodes as usize * cout;
|
||||
let samples = extent(off, 20, 1)?;
|
||||
let data: Vec<f32> = match prec {
|
||||
1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
|
||||
2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
|
||||
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
|
||||
};
|
||||
Ok(Stage::Clut(Clut::new(grid, cout, data)))
|
||||
}
|
||||
|
||||
/// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`.
|
||||
fn parse_text(d: &[u8]) -> String {
|
||||
let r = Reader { b: d };
|
||||
|
|
@ -775,8 +795,10 @@ fn parse_text(d: &[u8]) -> String {
|
|||
let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() };
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for k in 0..(n as usize).min(256) {
|
||||
let o = 16 + k * size as usize;
|
||||
let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break };
|
||||
// The record size comes from the file: `k * size` can overflow a 32-bit `usize`.
|
||||
let Ok(rec) = extent(16, k, size as usize).and_then(|o| r.slice(o, 12)) else { break };
|
||||
let rec = Reader { b: rec };
|
||||
let (Ok(lang), Ok(len), Ok(off)) = (rec.u16(0), rec.u32(4), rec.u32(8)) else { break };
|
||||
if best.is_none() || lang == u16::from_be_bytes(*b"en") {
|
||||
best = Some((off as usize, len as usize));
|
||||
if lang == u16::from_be_bytes(*b"en") {
|
||||
|
|
@ -792,3 +814,93 @@ fn parse_text(d: &[u8]) -> String {
|
|||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn be(v: u32) -> [u8; 4] {
|
||||
v.to_be_bytes()
|
||||
}
|
||||
|
||||
/// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on
|
||||
/// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow.
|
||||
#[test]
|
||||
fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() {
|
||||
let bytes = [0u8; 64];
|
||||
let r = Reader { b: &bytes };
|
||||
for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] {
|
||||
assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}");
|
||||
assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}");
|
||||
assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}");
|
||||
}
|
||||
assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated));
|
||||
assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated));
|
||||
assert_eq!(extent(16, 3, 12), Ok(52));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_count_is_checked_before_its_length_is_computed() {
|
||||
let mut d = b"curv\0\0\0\0".to_vec();
|
||||
d.extend(be(u32::MAX));
|
||||
assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into())));
|
||||
// Controls: the identity and a two-point table still parse with their lengths.
|
||||
let mut d = b"curv\0\0\0\0".to_vec();
|
||||
d.extend(be(0));
|
||||
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12));
|
||||
let mut d = b"curv\0\0\0\0".to_vec();
|
||||
d.extend(be(2));
|
||||
d.extend([0, 0, 0xFF, 0xFF]);
|
||||
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lut_ab_elements_at_hostile_offsets_are_rejected() {
|
||||
// A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT
|
||||
// offsets in turn point at the end of a 32-bit file.
|
||||
let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| {
|
||||
let mut d = b"mAB \0\0\0\0".to_vec();
|
||||
d.extend([3, 3, 0, 0]);
|
||||
for v in [b, matrix, m, clut, a] {
|
||||
d.extend(be(v));
|
||||
}
|
||||
for _ in 0..3 {
|
||||
d.extend(b"curv\0\0\0\0");
|
||||
d.extend(be(0));
|
||||
}
|
||||
d
|
||||
};
|
||||
assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses");
|
||||
let top = u32::MAX - 8;
|
||||
assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset");
|
||||
assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset");
|
||||
assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multilingual_records_with_a_huge_record_size_give_the_first_record() {
|
||||
let record = |lang: &[u8; 2], len: u32, off: u32| {
|
||||
let mut r = lang.to_vec();
|
||||
r.extend(b"US");
|
||||
r.extend(be(len));
|
||||
r.extend(be(off));
|
||||
r
|
||||
};
|
||||
// Two records of the standard 12 bytes: the English one wins.
|
||||
let mut d = b"mluc\0\0\0\0".to_vec();
|
||||
d.extend(be(2));
|
||||
d.extend(be(12));
|
||||
d.extend(record(b"de", 4, 40));
|
||||
d.extend(record(b"en", 4, 44));
|
||||
d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']);
|
||||
assert_eq!(parse_text(&d), "EN");
|
||||
// A record size of u32::MAX: the second record lies past any file, so the first is used.
|
||||
d[12..16].copy_from_slice(&be(u32::MAX));
|
||||
assert_eq!(parse_text(&d), "DE");
|
||||
// No records at all.
|
||||
let mut d = b"mluc\0\0\0\0".to_vec();
|
||||
d.extend(be(u32::MAX));
|
||||
d.extend(be(u32::MAX));
|
||||
assert_eq!(parse_text(&d), "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use crate::math;
|
|||
use crate::pipeline::Stage;
|
||||
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
|
||||
|
||||
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
|
||||
pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
|
||||
|
||||
/// Parameters of the synthetic profile.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
|||
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
|
||||
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
|
||||
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
|
||||
| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in |
|
||||
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
|
||||
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
|
||||
|
||||
|
|
@ -93,7 +94,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
|||
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
|
||||
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
|
||||
Other descriptors, signed or run-length-encoded data and other bit depths return
|
||||
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
|
||||
`CodecError::Unsupported` naming the field.
|
||||
|
||||
* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from),
|
||||
read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray)
|
||||
or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word
|
||||
boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white
|
||||
reference points are a log-conversion choice and are not applied. A depth other than 10 bits or
|
||||
a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than
|
||||
panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR.
|
||||
|
||||
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
|
||||
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ use half::f16;
|
|||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
|
||||
use crate::options::Limits;
|
||||
use crate::options::{Limits, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::OpenExr;
|
||||
|
||||
|
|
@ -39,9 +39,15 @@ fn unsup(reason: impl Into<String>) -> CodecError {
|
|||
// Every length and offset comes from the file, so every read is bounds-checked and a short
|
||||
// read is a malformed file, never a panic.
|
||||
|
||||
fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
let mut v = vec![0u8; n];
|
||||
cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?;
|
||||
fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> {
|
||||
// Borrow packed payloads from the input. Copying them here allocates before count-table
|
||||
// admission and needlessly duplicates the table even when the second pass just skips it.
|
||||
let truncated = || err(format!("truncated file while reading {what}"));
|
||||
let start = usize::try_from(cur.position()).map_err(|_| truncated())?;
|
||||
let end = start.checked_add(n).ok_or_else(truncated)?;
|
||||
let bytes = *cur.get_ref();
|
||||
let v = bytes.get(start..end).ok_or_else(truncated)?;
|
||||
cur.set_position(end as u64);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
|
|
@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) {
|
|||
}
|
||||
|
||||
/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half
|
||||
/// holding every second byte of the original; weave them back together.
|
||||
fn interleave_byte_blocks(buf: &mut [u8]) {
|
||||
/// holding every second byte of the original; weave them back together. The scratch copy is
|
||||
/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error.
|
||||
fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> {
|
||||
let half = buf.len().div_ceil(2);
|
||||
let mut out = vec![0u8; buf.len()];
|
||||
let mut out = alloc_zeroed::<u8>(buf.len())?;
|
||||
let mut k = 0usize;
|
||||
for i in 0..buf.len() / 2 {
|
||||
let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied());
|
||||
|
|
@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) {
|
|||
}
|
||||
}
|
||||
buf.copy_from_slice(&out);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`,
|
||||
/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and
|
||||
/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error.
|
||||
fn reserve_up_to(out: &mut Vec<u8>, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> {
|
||||
let need = out.len().saturating_add(extra);
|
||||
if need > limit {
|
||||
return Err(err(format!("{what}: data expands past {limit} bytes")));
|
||||
}
|
||||
if need > out.capacity() {
|
||||
let target = out.capacity().saturating_mul(2).max(need).min(limit);
|
||||
out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative
|
||||
/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate).
|
||||
fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
let mut out = Vec::with_capacity(expected.min(1 << 20));
|
||||
let mut out = Vec::new();
|
||||
reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?;
|
||||
let mut i = 0;
|
||||
while i < src.len() {
|
||||
let count = src[i] as i8;
|
||||
|
|
@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
|
|||
if out.len().saturating_add(n) > expected {
|
||||
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
|
||||
}
|
||||
reserve_up_to(&mut out, n, expected, what)?;
|
||||
if count < 0 {
|
||||
let end = i.saturating_add(n);
|
||||
let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
|
||||
|
|
@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
|
|||
/// has the uncompressed length is stored raw whatever the declared compression.
|
||||
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
|
||||
if packed.len() == expected {
|
||||
return Ok(packed.to_vec());
|
||||
let mut out = Vec::new();
|
||||
reserve_up_to(&mut out, expected, expected, what)?;
|
||||
out.extend_from_slice(packed);
|
||||
return Ok(out);
|
||||
}
|
||||
match compression {
|
||||
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
|
||||
|
|
@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s
|
|||
// Like ZIPS, RLE runs over the predicted, byte-split stream.
|
||||
let mut out = rle_decode(packed, expected, what)?;
|
||||
differences_to_samples(&mut out);
|
||||
interleave_byte_blocks(&mut out);
|
||||
interleave_byte_blocks(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
Compression::ZIP1 => {
|
||||
let mut out = Vec::with_capacity(expected.min(1 << 20));
|
||||
flate2::read::ZlibDecoder::new(packed)
|
||||
.take(expected as u64 + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| err(format!("{what}: zlib data is malformed")))?;
|
||||
// Inflate straight into `out`, grown with `reserve_up_to` rather than by
|
||||
// `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are
|
||||
// in, a one-byte read checks that the stream ends there.
|
||||
let malformed = || err(format!("{what}: zlib data is malformed"));
|
||||
let mut zlib = flate2::read::ZlibDecoder::new(packed);
|
||||
let mut out = Vec::new();
|
||||
let mut filled = 0;
|
||||
while filled < expected {
|
||||
if filled == out.len() {
|
||||
let grow = out.len().max(1 << 13).min(expected - filled);
|
||||
reserve_up_to(&mut out, grow, expected, what)?;
|
||||
out.resize(filled + grow, 0);
|
||||
}
|
||||
let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
filled += n;
|
||||
}
|
||||
out.truncate(filled);
|
||||
if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 {
|
||||
return Err(err(format!("{what}: zlib data expands past {expected} bytes")));
|
||||
}
|
||||
if out.len() != expected {
|
||||
return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len())));
|
||||
}
|
||||
differences_to_samples(&mut out);
|
||||
interleave_byte_blocks(&mut out);
|
||||
interleave_byte_blocks(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
|
||||
|
|
@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
|
|||
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
|
||||
return Err(err("deep chunk sizes point past the end of the file"));
|
||||
}
|
||||
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
|
||||
let what = "the sample-count table";
|
||||
let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;
|
||||
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
|
||||
// A compressed clipped table also needs its scratch copy. The data-window check only
|
||||
// budgets four bytes per channel per pixel, insufficient for a one-channel image.
|
||||
// An equal-sized payload takes decompress's raw path, which allocates no scratch.
|
||||
let buffers = if table_bytes.len() == expected_table { 1 } else { 2 };
|
||||
let clipped_need = (expected_table as u64).saturating_mul(buffers);
|
||||
if clipped_need > limits.max_alloc {
|
||||
return Err(CodecError::LimitExceeded(format!(
|
||||
"{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}",
|
||||
limits.max_alloc
|
||||
)));
|
||||
}
|
||||
// An edge tile's table may still be stored at the full tile size (its pixels first, in
|
||||
// rows of the clipped width, then unused entries), as the OpenEXR library writes it.
|
||||
let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table);
|
||||
let raw = decompress(compression, &table_bytes, full_table, "the sample-count table")
|
||||
.or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?;
|
||||
// That size comes from the header's tile size alone, which nothing ties to the data
|
||||
// window, so it is tried only when `max_alloc` has room for the table and for the copy
|
||||
// that undoes its byte split. Otherwise only the clipped size is read.
|
||||
let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4));
|
||||
let raw = match full_table {
|
||||
// Scan lines and interior tiles: the full size is the clipped size.
|
||||
Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?,
|
||||
Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => {
|
||||
decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))?
|
||||
}
|
||||
_ => decompress(compression, table_bytes, expected_table, what).map_err(|_| {
|
||||
// Four bytes per pixel of the tile, twice: the table and its scratch copy.
|
||||
let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8);
|
||||
CodecError::LimitExceeded(format!(
|
||||
"{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}",
|
||||
limits.max_alloc
|
||||
))
|
||||
})?,
|
||||
};
|
||||
|
||||
// The table is cumulative within each row of the block (a tile restarts at every row);
|
||||
// `prefix` is cumulative over the whole block, and each pixel's own count is the difference.
|
||||
|
|
@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
|
|||
// The count table sits between the sizes and the sample data; skip its bytes.
|
||||
read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
|
||||
let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;
|
||||
let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
|
||||
let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
|
||||
// Row by row (a scan line block is one row), and within a row channel by channel, as
|
||||
// flat tiles are laid out: each pixel's samples are contiguous within its channel.
|
||||
let mut in_off = 0usize;
|
||||
|
|
@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
|
|||
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write as _;
|
||||
|
||||
#[test]
|
||||
fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() {
|
||||
let bytes = [1u8, 2, 3, 4];
|
||||
let mut cur = Cursor::new(bytes.as_slice());
|
||||
let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes");
|
||||
assert_eq!(prefix, &[1, 2, 3]);
|
||||
assert_eq!(prefix.as_ptr(), bytes.as_ptr());
|
||||
assert_eq!(cur.position(), 3);
|
||||
assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. })));
|
||||
assert_eq!(cur.position(), 3);
|
||||
assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]);
|
||||
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
|
||||
cur.set_position(u64::MAX);
|
||||
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
|
||||
}
|
||||
|
||||
/// What the writer stores before compressing: the byte split, then the +128 predictor.
|
||||
fn predicted(raw: &[u8]) -> Vec<u8> {
|
||||
let half = raw.len().div_ceil(2);
|
||||
let mut out = vec![0u8; raw.len()];
|
||||
for (i, &v) in raw.iter().enumerate() {
|
||||
out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v;
|
||||
}
|
||||
let mut prev = 0i16;
|
||||
for (i, b) in out.iter_mut().enumerate() {
|
||||
let v = i16::from(*b);
|
||||
if i > 0 {
|
||||
*b = (v - prev + 128) as u8;
|
||||
}
|
||||
prev = v;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count
|
||||
/// table, with the copy that undoes its byte split), so decompression must not reserve
|
||||
/// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table
|
||||
/// 4 MiB, so the "admitted" table and its copy took up to three times the table size.
|
||||
#[test]
|
||||
fn decompressed_buffers_reserve_no_more_than_expected() {
|
||||
for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] {
|
||||
let raw = vec![0u8; n];
|
||||
let p = predicted(&raw);
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
z.write_all(&p).expect("write to a Vec");
|
||||
let zips = z.finish().expect("finish a Vec");
|
||||
// RLE: the first byte as a literal, then runs of up to 128 copies of the next.
|
||||
let mut rle = vec![0xFF, p[0]];
|
||||
for run in p[1..].chunks(128) {
|
||||
rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]);
|
||||
}
|
||||
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
|
||||
let out = decompress(compression, packed, n, "the table").expect("decompresses");
|
||||
assert_eq!(out, raw, "{compression:?}, {n} bytes");
|
||||
assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity());
|
||||
}
|
||||
// One byte short, the same data expands past the expected size and is refused.
|
||||
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
|
||||
assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A size past the limit is malformed data, and a reservation the allocator refuses is an
|
||||
/// error rather than an abort.
|
||||
#[test]
|
||||
fn reservations_past_the_limit_or_the_allocator_are_errors() {
|
||||
let mut v = Vec::new();
|
||||
assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. })));
|
||||
assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_))));
|
||||
assert_eq!(v.capacity(), 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,6 +337,78 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
Ok(img)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Cineon (Kodak .cin)
|
||||
//
|
||||
// Cineon is the 10-bit film-scan format DPX grew from (same Kodak header family, big-endian).
|
||||
// The generic header is 1024 bytes: the file information fields, then at offset 192 the image
|
||||
// orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors
|
||||
// (the first carries the bits per pixel and the line/image dimensions). Image data is three
|
||||
// 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as
|
||||
// DPX's 10-bit packing method A, so the unpacking is shared.
|
||||
|
||||
/// The Cineon magic number (`802A5FD7` hex), big-endian.
|
||||
pub(crate) fn is_cineon(b: &[u8]) -> bool {
|
||||
b.get(..4) == Some(&[0x80, 0x2A, 0x5F, 0xD7])
|
||||
}
|
||||
|
||||
/// The EXIF orientation (1–8) for a Cineon header's orientation code (the same 0–7 order as DPX).
|
||||
pub(crate) fn cineon_orientation(b: &[u8]) -> u16 {
|
||||
let Some(code) = b.get(192).copied() else { return 1 };
|
||||
match code {
|
||||
1 => 2,
|
||||
2 => 4,
|
||||
3 => 3,
|
||||
4 => 5,
|
||||
5 => 6,
|
||||
6 => 8,
|
||||
7 => 7,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a Cineon (`.cin`) frame: 10-bit logarithmic film scans, big-endian, one (gray) or
|
||||
/// three (RGB) channels. The 10-bit code values become U16 as `round(c × 65535 / 1023)`; the
|
||||
/// file's black/white reference points are not applied (a log conversion is a display choice).
|
||||
pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
if !is_cineon(bytes) {
|
||||
return Err(err("missing the Cineon magic number (802A5FD7)"));
|
||||
}
|
||||
let o = Order::Big;
|
||||
let missing = |what: &str| err(format!("the header ends before {what}"));
|
||||
let image_offset = usize::try_from(o.u32(bytes, 4).ok_or_else(|| missing("the image data offset"))?).map_err(|_| err("image data offset out of range"))?;
|
||||
let channels = *bytes.get(193).ok_or_else(|| missing("the channel count"))?;
|
||||
let bits = *bytes.get(198).ok_or_else(|| missing("the bits per pixel"))?;
|
||||
if bits != 10 {
|
||||
return Err(unsupported(format!("{bits}-bit samples (Cineon frames are 10-bit)")));
|
||||
}
|
||||
let layout = match channels {
|
||||
1 => ChannelLayout::Gray,
|
||||
3 => ChannelLayout::Rgb,
|
||||
n => return Err(unsupported(format!("{n} channels (Cineon frames have 1 or 3)"))),
|
||||
};
|
||||
let width = o.u32(bytes, 200).ok_or_else(|| missing("the pixels per line"))?;
|
||||
let height = o.u32(bytes, 204).ok_or_else(|| missing("the lines per image"))?;
|
||||
limits.check(width, height, layout, SampleType::U16)?;
|
||||
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let per_line = w.checked_mul(layout.channels()).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
|
||||
// Three 10-bit samples per 32-bit word, lines on a word boundary (DPX packing 1).
|
||||
let line_len = line_bytes(per_line, 10, 1).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
|
||||
let data = bytes.get(image_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?;
|
||||
let need = line_len.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?;
|
||||
if data.len() < need {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
let mut codes = vec![0u16; per_line.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?];
|
||||
for (y, row) in codes.chunks_exact_mut(per_line).enumerate() {
|
||||
let start = y * line_len;
|
||||
let line = data.get(start..start + line_len).ok_or_else(|| err("truncated image data"))?;
|
||||
unpack_line(line, o, 10, 1, row)?;
|
||||
}
|
||||
let wide: Vec<u16> = codes.iter().map(|&c| widen(c, 1023)).collect();
|
||||
Image::from_u16(width, height, layout, &wide)
|
||||
}
|
||||
|
||||
/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out.
|
||||
fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> {
|
||||
let mut text = Vec::new();
|
||||
|
|
@ -653,4 +725,60 @@ mod tests {
|
|||
let img = decode(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]);
|
||||
}
|
||||
|
||||
/// A minimal Cineon: a 1024-byte generic header, then `w × h` 10-bit samples packed three
|
||||
/// to a 32-bit word (RGB in word order, or gray three pixels per word).
|
||||
fn cineon(w: u32, h: u32, channels: u8, px: &[[u16; 3]]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 1024];
|
||||
b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); // magic (802A5FD7)
|
||||
b[4..8].copy_from_slice(&1024u32.to_be_bytes()); // image data offset
|
||||
b[192] = 0; // orientation: left-to-right, top-to-bottom
|
||||
b[193] = channels;
|
||||
b[198] = 10; // bits per pixel (channel 1)
|
||||
b[200..204].copy_from_slice(&w.to_be_bytes()); // pixels per line
|
||||
b[204..208].copy_from_slice(&h.to_be_bytes()); // lines per image
|
||||
let word = |a: u16, c: u16, e: u16| ((u32::from(a) << 22) | (u32::from(c) << 12) | (u32::from(e) << 2)).to_be_bytes();
|
||||
if channels == 3 {
|
||||
for p in px {
|
||||
b.extend_from_slice(&word(p[0], p[1], p[2]));
|
||||
}
|
||||
} else {
|
||||
let g = |i: usize| px.get(i).map_or(0, |p| p[0]);
|
||||
for i in (0..px.len()).step_by(3) {
|
||||
b.extend_from_slice(&word(g(i), g(i + 1), g(i + 2)));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_reads_ten_bit_rgb() {
|
||||
let px = [[0, 512, 1023], [1023, 0, 256]];
|
||||
let b = cineon(2, 1, 3, &px);
|
||||
assert!(is_cineon(&b));
|
||||
assert_eq!(order(&b), None, "Cineon is not DPX");
|
||||
let img = decode_cineon(&b, &Limits::default()).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb));
|
||||
assert_eq!(
|
||||
img.to_u16_samples().unwrap(),
|
||||
vec![widen(0, 1023), widen(512, 1023), widen(1023, 1023), widen(1023, 1023), widen(0, 1023), widen(256, 1023)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_reads_grayscale_and_rejects_other_depths() {
|
||||
let px = [[10, 0, 0], [20, 0, 0], [30, 0, 0], [40, 0, 0]];
|
||||
let b = cineon(4, 1, 1, &px);
|
||||
let img = decode_cineon(&b, &Limits::default()).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::Gray);
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![widen(10, 1023), widen(20, 1023), widen(30, 1023), widen(40, 1023)]);
|
||||
// A depth other than 10 is unsupported, and truncation errors rather than panicking.
|
||||
let mut bad = cineon(1, 1, 3, &[[0, 0, 0]]);
|
||||
bad[198] = 8;
|
||||
assert!(matches!(decode_cineon(&bad, &Limits::default()), Err(CodecError::Unsupported { .. })));
|
||||
let full = cineon(4, 4, 3, &[[0; 3]; 16]);
|
||||
for cut in [0, 4, 200, 1024, full.len() - 1] {
|
||||
assert!(decode_cineon(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
Ok(img)
|
||||
}
|
||||
|
||||
/// The size of the part [`decode`] opens, from the headers alone. Deep files are refused (their
|
||||
/// flattened size comes from the deep decoder), as is a part [`decode`] cannot show.
|
||||
pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32), CodecError> {
|
||||
let meta = read_meta(bytes, limits)?;
|
||||
if meta.requirements.has_deep_data {
|
||||
return Err(CodecError::unsupported(F, "no header-only size probe for deep data"));
|
||||
}
|
||||
let pick = pick_part(&meta);
|
||||
let info = meta.headers.get(pick).map(|h| part_info(pick, h)).ok_or_else(|| err("no parts"))?;
|
||||
if info.color_rank() == 0 {
|
||||
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
|
||||
}
|
||||
Ok((info.width, info.height))
|
||||
}
|
||||
|
||||
/// Decode one part of a multi-part file by its index into [`info`]'s list. A file with
|
||||
/// deep parts is not mixed: the deep decoder composites those instead.
|
||||
pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result<Image, CodecError> {
|
||||
|
|
|
|||
|
|
@ -530,7 +530,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
for p in 0..nc {
|
||||
for y in 0..hu {
|
||||
for x in 0..wu {
|
||||
out.extend_from_slice(&d[idx(p, y, x)].to_be_bytes());
|
||||
// 0..1 as documented: a value above 2 would make readers (ours too)
|
||||
// take the data as ADU and scale it by 65535. NaN is written as 0.
|
||||
let v = d[idx(p, y, x)];
|
||||
let v = if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) };
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -632,6 +636,17 @@ mod tests {
|
|||
assert_eq!(img.to_f32_samples().unwrap(), vec![1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_float_saves_clamped_and_reopens_as_saved() {
|
||||
// One value above 2 used to make the reader scale everything by 65535 (all black).
|
||||
let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap();
|
||||
let opts = EncodeOptions::default();
|
||||
let bytes = encode(&img, crate::fidelity::plan(&img, F, &opts), &opts).unwrap();
|
||||
let back = decode(&bytes, &Limits::default()).unwrap().to_f32_samples().unwrap();
|
||||
assert!((back[0] - 0.5).abs() < 1e-6, "{back:?}");
|
||||
assert!((back[1] - 1.0).abs() < 1e-6, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_negative_data_falls_back_to_float() {
|
||||
let mut b = header(&["SIMPLE = T", "BITPIX = 16", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
|
||||
|
|
|
|||
|
|
@ -532,17 +532,24 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
|||
} else {
|
||||
None
|
||||
};
|
||||
// CIE L*a*b*: CIELab (8; a* and b* signed) and ICCLab (9; a* and b* offset) are handed to the
|
||||
// `tiff` crate as RGB, then labelled Lab with a* and b* in PhotoCraft's encoding
|
||||
// (`convert_ab`).
|
||||
let lab = matches!(photometric, 8 | 9);
|
||||
if lab && (spp < 3 || !matches!(src, Src::U8 | Src::U16)) {
|
||||
return Err(CodecError::unsupported(F, format!("Lab image with {spp} samples of {bits} bits")));
|
||||
}
|
||||
// TIFF 6.0 lets any extra sample be the alpha (#1349). For RGB and CMYK the `tiff` crate
|
||||
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
|
||||
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
|
||||
// position past the samples the file has is ignored.
|
||||
let color = match photometric {
|
||||
2 => 3,
|
||||
2 | 8 | 9 => 3,
|
||||
5 => 4,
|
||||
_ => 1,
|
||||
};
|
||||
let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp);
|
||||
let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0);
|
||||
let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0);
|
||||
let mut patches: Vec<(u64, Vec<u8>)> = Vec::new();
|
||||
let encode_u = |v: u64, len: usize| -> Vec<u8> {
|
||||
let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() };
|
||||
|
|
@ -557,14 +564,14 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
|||
// The directory is seen as the last of its chain: the chain was walked already, and a
|
||||
// cycle through it must not fail this page.
|
||||
patches.push((dir.next_at, vec![0; if f.big { 8 } else { 4 }]));
|
||||
if invert || palette.is_some() || all_samples {
|
||||
if invert || palette.is_some() || all_samples || lab {
|
||||
let e = photometric_entry.ok_or_else(|| err("missing PhotometricInterpretation"))?;
|
||||
let len = match e.ty {
|
||||
3 if e.count == 1 => 2,
|
||||
4 if e.count == 1 => 4,
|
||||
_ => return Err(err("malformed PhotometricInterpretation")),
|
||||
};
|
||||
patches.push((e.value_at, encode_u(1, len)));
|
||||
patches.push((e.value_at, encode_u(if lab && !all_samples { 2 } else { 1 }, len)));
|
||||
}
|
||||
|
||||
let tl = {
|
||||
|
|
@ -585,7 +592,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
|||
_ if palette.is_some() => (ChannelLayout::Rgb, 1),
|
||||
tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1),
|
||||
tiff::ColorType::Multiband { num_samples, .. } if all_samples => {
|
||||
(if photometric == 2 { ChannelLayout::Rgba } else { ChannelLayout::CmykA }, usize::from(num_samples))
|
||||
(if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples))
|
||||
}
|
||||
tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => {
|
||||
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
|
||||
|
|
@ -596,6 +603,12 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
|||
tiff::ColorType::CMYKA(_) => (ChannelLayout::CmykA, 5),
|
||||
other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))),
|
||||
};
|
||||
let out_layout = match out_layout {
|
||||
ChannelLayout::Rgb if lab => ChannelLayout::Lab,
|
||||
ChannelLayout::Rgba if lab => ChannelLayout::LabA,
|
||||
_ if lab => return Err(err("Lab image that does not read as three colour samples")),
|
||||
l => l,
|
||||
};
|
||||
let chunk_spp = if planar { 1 } else { chunk_spp };
|
||||
// The sample (chunky) or plane (planar) that holds the alpha: the colour samples come first.
|
||||
let alpha_sample = out_layout.color_channels() + alpha_at.unwrap_or(0);
|
||||
|
|
@ -676,6 +689,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
|||
if tally.decoded == 0 && tally.missing > 0 {
|
||||
return Err(err("the image data is missing (cut off or past the end of the file)"));
|
||||
}
|
||||
if lab {
|
||||
convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false);
|
||||
}
|
||||
let mut img = Image::from_raw(w, h, out_layout, out, data)?;
|
||||
if tally.missing > 0 {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
|
|
@ -683,6 +699,35 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
|||
Ok(img)
|
||||
}
|
||||
|
||||
/// The a* and b* samples (1 and 2 of every pixel) of a Lab image between the file's encoding and
|
||||
/// PhotoCraft's (8 bits: `a + 128`; 16 bits: `(a + 128) · 257`). In the file, CIELab holds them
|
||||
/// signed (8 bits: `a`; 16 bits: `a · 256`) and ICCLab offset (8 bits: `a + 128`; 16 bits:
|
||||
/// `(a + 128) · 256`). `to_file` picks the direction; samples are native-endian.
|
||||
fn convert_ab(data: &mut [u8], channels: usize, sample: SampleType, signed: bool, to_file: bool) {
|
||||
match sample {
|
||||
SampleType::U8 if signed => data.chunks_exact_mut(channels).for_each(|px| {
|
||||
px[1] ^= 0x80;
|
||||
px[2] ^= 0x80;
|
||||
}),
|
||||
SampleType::U16 => data.chunks_exact_mut(channels * 2).for_each(|px| {
|
||||
for c in [1, 2] {
|
||||
let v = u16::from_ne_bytes([px[2 * c], px[2 * c + 1]]);
|
||||
// The file's value as `a · 256` (signed) and back.
|
||||
let file_a256 = |v: u16| if signed { f64::from(v as i16) } else { f64::from(v) - 32768.0 };
|
||||
let out = if to_file {
|
||||
let a256 = (f64::from(v) - 32896.0) * 256.0 / 257.0;
|
||||
let a256 = a256.round().clamp(-32768.0, 32767.0);
|
||||
if signed { (a256 as i16) as u16 } else { (a256 + 32768.0) as u16 }
|
||||
} else {
|
||||
(file_a256(v) * 257.0 / 256.0 + 32896.0).round().clamp(0.0, 65535.0) as u16
|
||||
};
|
||||
px[2 * c..2 * c + 2].copy_from_slice(&out.to_ne_bytes());
|
||||
}
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first
|
||||
/// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over
|
||||
/// all cores.
|
||||
|
|
@ -1021,6 +1066,11 @@ custom_colortype!(GrayA16, u16, PI::BlackIsZero, &[16, 16], &[SampleFormat::Uint
|
|||
custom_colortype!(GrayA32F, f32, PI::BlackIsZero, &[32, 32], &[SampleFormat::IEEEFP; 2], float);
|
||||
custom_colortype!(CmykA16, u16, PI::CMYK, &[16; 5], &[SampleFormat::Uint; 5], int);
|
||||
custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5], float);
|
||||
// CIELab as Photoshop writes it: a* and b* signed, the sample format left unsigned.
|
||||
custom_colortype!(Lab8, u8, PI::CIELab, &[8; 3], &[SampleFormat::Uint; 3], int);
|
||||
custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], int);
|
||||
custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int);
|
||||
custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int);
|
||||
|
||||
struct TagSet<'a> {
|
||||
icc: Option<&'a [u8]>,
|
||||
|
|
@ -1119,6 +1169,20 @@ fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img:
|
|||
(L::CmykA, S::U8) => write_one::<colortype::CMYKA8, K>(enc, w, h, d8, tags),
|
||||
(L::CmykA, S::U16) => write_one::<CmykA16, K>(enc, w, h, &u16s(), tags),
|
||||
(L::CmykA, S::F32) => write_one::<CmykA32F, K>(enc, w, h, &f32s(), tags),
|
||||
(L::Lab | L::LabA, _) => {
|
||||
// CIELab has no float form: 32-bit Lab is written with 16 bits.
|
||||
let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 };
|
||||
let mut file = img.convert(img.layout(), sample);
|
||||
let channels = file.layout().channels();
|
||||
convert_ab(file.data_mut(), channels, sample, true, true);
|
||||
let u16s = || file.to_u16_samples().unwrap_or_default();
|
||||
match (file.layout(), sample) {
|
||||
(L::Lab, S::U8) => write_one::<Lab8, K>(enc, w, h, file.data(), tags),
|
||||
(L::Lab, _) => write_one::<Lab16, K>(enc, w, h, &u16s(), tags),
|
||||
(_, S::U8) => write_one::<LabA8, K>(enc, w, h, file.data(), tags),
|
||||
_ => write_one::<LabA16, K>(enc, w, h, &u16s(), tags),
|
||||
}
|
||||
}
|
||||
(l, s) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported {l:?} {s:?}")).into()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecErr
|
|||
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
/// The `image` decoder for `f` with our limits, its header read and its size checked.
|
||||
fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result<impl ImageDecoder + 'a, CodecError> {
|
||||
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
|
||||
let mut il = image::Limits::default();
|
||||
|
|
@ -45,9 +46,14 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
|
|||
il.max_image_height = Some(limits.max_height);
|
||||
il.max_alloc = Some(limits.max_alloc);
|
||||
reader.limits(il);
|
||||
let mut dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
|
||||
let dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
|
||||
let (w, h) = dec.dimensions();
|
||||
limits.check_bytes(w, h, dec.color_type().bytes_per_pixel() as u64)?;
|
||||
Ok(dec)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let mut dec = open(f, bytes, limits)?;
|
||||
let icc = dec.icc_profile().ok().flatten();
|
||||
let exif = dec.exif_metadata().ok().flatten();
|
||||
let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?;
|
||||
|
|
@ -60,6 +66,18 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
|
|||
Ok(img)
|
||||
}
|
||||
|
||||
/// The stored size and EXIF block [`decode`] would find, from the decoder's header alone: the
|
||||
/// same reader, limits and size check, without decoding pixels. AVIF is refused: its decoder
|
||||
/// decodes the image as it opens.
|
||||
pub(crate) fn probe(f: Format, bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<Vec<u8>>), CodecError> {
|
||||
if f == Format::Avif {
|
||||
return Err(CodecError::unsupported(f, "no header-only size probe"));
|
||||
}
|
||||
let mut dec = open(f, bytes, limits)?;
|
||||
let (w, h) = dec.dimensions();
|
||||
Ok((w, h, dec.exif_metadata().ok().flatten()))
|
||||
}
|
||||
|
||||
/// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any.
|
||||
/// The total is unknown when the blocks end before the trailer or break off.
|
||||
fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ fn err(e: impl std::fmt::Display) -> CodecError {
|
|||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
type Decoder<'a> = image_webp::WebPDecoder<Cursor<&'a [u8]>>;
|
||||
|
||||
/// The decoder with its chunks read, the stored size and the output layout, the size checked.
|
||||
fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32, ChannelLayout), CodecError> {
|
||||
let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?;
|
||||
dec.set_memory_limit(limits.alloc_usize());
|
||||
let (w, h) = dec.dimensions();
|
||||
|
|
@ -26,6 +29,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
// VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the
|
||||
// ratio is generous; it still stops a header-only file from asking for a gigabyte.
|
||||
Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?;
|
||||
Ok((dec, w, h, layout))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let (mut dec, w, h, layout) = open(bytes, limits)?;
|
||||
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let mut buf = alloc_zeroed::<u8>(size)?;
|
||||
dec.read_image(&mut buf).map_err(err)?;
|
||||
|
|
@ -41,6 +49,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
Ok(img)
|
||||
}
|
||||
|
||||
/// The stored size and EXIF block [`decode`] would find, from the container's chunks alone: the
|
||||
/// same header reads and size checks, without decoding the bitstream.
|
||||
pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<Vec<u8>>), CodecError> {
|
||||
let (mut dec, w, h, _) = open(bytes, limits)?;
|
||||
Ok((w, h, dec.exif_metadata().ok().flatten()))
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
if !opts.webp_lossless {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ pub enum FidelityWarning {
|
|||
CmykConverted {
|
||||
to: ChannelLayout,
|
||||
},
|
||||
/// L*a*b* data will be converted to another model (sRGB, D50 to D65).
|
||||
LabConverted {
|
||||
to: ChannelLayout,
|
||||
},
|
||||
/// Colour data will be reduced to grayscale.
|
||||
ColorToGray,
|
||||
/// The image will be quantized to a 256-colour palette.
|
||||
|
|
@ -84,6 +88,7 @@ impl fmt::Display for FidelityWarning {
|
|||
CmykConverted { to } => {
|
||||
write!(f, "CMYK will be converted to {to:?} (not colour-managed)")
|
||||
}
|
||||
LabConverted { to } => write!(f, "Lab will be converted to {to:?} (sRGB)"),
|
||||
ColorToGray => write!(f, "colour will be converted to grayscale"),
|
||||
PaletteQuantized => write!(f, "colours will be quantized to a 256-colour palette"),
|
||||
LossyCompression => write!(f, "lossy compression"),
|
||||
|
|
@ -129,8 +134,8 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
|
|||
if layouts.contains(&cand) {
|
||||
return cand;
|
||||
}
|
||||
// CMYK falls back to RGB; gray expands to RGB.
|
||||
if cand.is_cmyk() || cand.is_gray() {
|
||||
// CMYK and Lab fall back to RGB; gray expands to RGB.
|
||||
if cand.is_cmyk() || cand.is_lab() || cand.is_gray() {
|
||||
cand = if cand.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
}
|
||||
if layouts.contains(&cand) {
|
||||
|
|
@ -158,6 +163,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
|
|||
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
|
||||
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
|
||||
},
|
||||
// CIELab has no float form: float Lab is written with 16 bits.
|
||||
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
|
||||
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
|
||||
}
|
||||
}
|
||||
|
|
@ -195,7 +202,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
|||
if lossy_depth {
|
||||
w.push(W::DepthReduced { from: ss, to: p.sample });
|
||||
}
|
||||
if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() {
|
||||
// FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too.
|
||||
if ss.is_float() && (!p.sample.is_float() || format == Format::Fits) && image.has_out_of_range() {
|
||||
w.push(W::RangeClipped);
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +217,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
|||
if sl.is_cmyk() && !p.layout.is_cmyk() {
|
||||
w.push(W::CmykConverted { to: p.layout });
|
||||
}
|
||||
if sl.is_lab() && !p.layout.is_lab() {
|
||||
w.push(W::LabConverted { to: p.layout });
|
||||
}
|
||||
if !sl.is_gray() && p.layout.is_gray() {
|
||||
w.push(W::ColorToGray);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ pub enum Format {
|
|||
Fits,
|
||||
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
|
||||
Dpx,
|
||||
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only.
|
||||
Cineon,
|
||||
}
|
||||
|
||||
/// What a format can hold **and** what this crate reads/writes for it.
|
||||
|
|
@ -78,6 +80,11 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
|||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
|
||||
),
|
||||
(
|
||||
Format::Cineon,
|
||||
"Cineon frames are imported for film work; this crate has no Cineon writer, so save an \
|
||||
imported frame as DPX, TIFF or EXR instead.",
|
||||
),
|
||||
(
|
||||
Format::Heif,
|
||||
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
|
||||
|
|
@ -92,10 +99,12 @@ use SampleType as S;
|
|||
|
||||
const RGB_GRAY: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba];
|
||||
const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA];
|
||||
/// TIFF also stores CIE L*a*b* (CIELab).
|
||||
const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA, L::Lab, L::LabA];
|
||||
|
||||
impl Format {
|
||||
/// Every format known to the crate (enabled or not).
|
||||
pub const ALL: [Format; 19] = [
|
||||
pub const ALL: [Format; 20] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
|
|
@ -115,6 +124,7 @@ impl Format {
|
|||
Format::Heif,
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
Format::Cineon,
|
||||
];
|
||||
|
||||
pub fn caps(self) -> FormatCaps {
|
||||
|
|
@ -148,6 +158,7 @@ impl Format {
|
|||
Format::Heif => "HEIF",
|
||||
Format::Fits => "FITS",
|
||||
Format::Dpx => "DPX",
|
||||
Format::Cineon => "Cineon",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +185,7 @@ impl Format {
|
|||
Format::Heif => &["heic", "heif", "hif"],
|
||||
Format::Fits => &["fits", "fit", "fts"],
|
||||
Format::Dpx => &["dpx"],
|
||||
Format::Cineon => &["cin"],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +210,7 @@ impl Format {
|
|||
Format::Heif => "image/heif",
|
||||
Format::Fits => "image/fits",
|
||||
Format::Dpx => "image/x-dpx",
|
||||
Format::Cineon => "image/x-cin",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,7 +246,7 @@ pub fn caps(format: Format) -> FormatCaps {
|
|||
FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base }
|
||||
}
|
||||
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
|
||||
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
|
||||
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
|
||||
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
|
||||
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
|
||||
Format::Bmp => base,
|
||||
|
|
@ -253,6 +266,7 @@ pub fn caps(format: Format) -> FormatCaps {
|
|||
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
|
||||
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
|
||||
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
|
||||
Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +321,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
|||
if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") {
|
||||
return Some(Format::Dpx);
|
||||
}
|
||||
if crate::codecs::dpx::is_cineon(b) {
|
||||
return Some(Format::Cineon);
|
||||
}
|
||||
if b.starts_with(b"SIMPLE =") {
|
||||
return Some(Format::Fits);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,19 +21,31 @@ pub enum ChannelLayout {
|
|||
/// Ink amounts, `0 = no ink`, `max = full ink` (not Adobe-inverted).
|
||||
Cmyk,
|
||||
CmykA,
|
||||
/// CIE L*a*b* (D50), as PhotoCraft's Lab documents hold it: L* 0..100 as 0..1, a* and b*
|
||||
/// −128..127 as `(v + 128) / 255` (Photoshop's unsigned encoding).
|
||||
Lab,
|
||||
LabA,
|
||||
}
|
||||
|
||||
impl ChannelLayout {
|
||||
pub const ALL: [ChannelLayout; 6] =
|
||||
[ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA];
|
||||
pub const ALL: [ChannelLayout; 8] = [
|
||||
ChannelLayout::Gray,
|
||||
ChannelLayout::GrayA,
|
||||
ChannelLayout::Rgb,
|
||||
ChannelLayout::Rgba,
|
||||
ChannelLayout::Cmyk,
|
||||
ChannelLayout::CmykA,
|
||||
ChannelLayout::Lab,
|
||||
ChannelLayout::LabA,
|
||||
];
|
||||
|
||||
/// Total number of channels including alpha.
|
||||
pub const fn channels(self) -> usize {
|
||||
match self {
|
||||
ChannelLayout::Gray => 1,
|
||||
ChannelLayout::GrayA => 2,
|
||||
ChannelLayout::Rgb => 3,
|
||||
ChannelLayout::Rgba | ChannelLayout::Cmyk => 4,
|
||||
ChannelLayout::Rgb | ChannelLayout::Lab => 3,
|
||||
ChannelLayout::Rgba | ChannelLayout::Cmyk | ChannelLayout::LabA => 4,
|
||||
ChannelLayout::CmykA => 5,
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +56,7 @@ impl ChannelLayout {
|
|||
}
|
||||
|
||||
pub const fn has_alpha(self) -> bool {
|
||||
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA)
|
||||
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA | ChannelLayout::LabA)
|
||||
}
|
||||
|
||||
pub const fn is_gray(self) -> bool {
|
||||
|
|
@ -59,12 +71,17 @@ impl ChannelLayout {
|
|||
matches!(self, ChannelLayout::Cmyk | ChannelLayout::CmykA)
|
||||
}
|
||||
|
||||
pub const fn is_lab(self) -> bool {
|
||||
matches!(self, ChannelLayout::Lab | ChannelLayout::LabA)
|
||||
}
|
||||
|
||||
/// The same colour model with alpha added.
|
||||
pub const fn with_alpha(self) -> Self {
|
||||
match self {
|
||||
ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::GrayA,
|
||||
ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgba,
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::CmykA,
|
||||
ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::LabA,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,12 +91,13 @@ impl ChannelLayout {
|
|||
ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::Gray,
|
||||
ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgb,
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::Cmyk,
|
||||
ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::Lab,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if both layouts share a colour model (ignoring alpha).
|
||||
pub const fn same_model(self, other: Self) -> bool {
|
||||
(self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk())
|
||||
(self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk()) || (self.is_lab() && other.is_lab())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -723,6 +741,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout
|
|||
ChannelLayout::Gray | ChannelLayout::GrayA => [color[0]; 3],
|
||||
ChannelLayout::Rgb | ChannelLayout::Rgba => [color[0], color[1], color[2]],
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => cmyk_to_rgb(color[0], color[1], color[2], color[3]),
|
||||
ChannelLayout::Lab | ChannelLayout::LabA => crate::lab::lab_to_srgb([color[0], color[1], color[2]]),
|
||||
};
|
||||
match to {
|
||||
ChannelLayout::Gray | ChannelLayout::GrayA => {
|
||||
|
|
@ -730,6 +749,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout
|
|||
}
|
||||
ChannelLayout::Rgb | ChannelLayout::Rgba => out.extend_from_slice(&rgb),
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])),
|
||||
ChannelLayout::Lab | ChannelLayout::LabA => out.extend_from_slice(&crate::lab::srgb_to_lab(rgb)),
|
||||
}
|
||||
}
|
||||
if to.has_alpha() {
|
||||
|
|
|
|||
56
crates/codecs/src/lab.rs
Normal file
56
crates/codecs/src/lab.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! CIE L*a*b* (D50) ⇄ encoded sRGB for [`crate::ChannelLayout::Lab`] samples, the same formulas
|
||||
//! and constants as `photocraft-color` (this crate stays standalone). Samples are normalized as
|
||||
//! PhotoCraft holds Lab: L* 0..100 as 0..1, a* and b* −128..127 as `(v + 128) / 255`.
|
||||
|
||||
const D50: [f32; 3] = [0.964_22, 1.0, 0.825_21];
|
||||
/// Linear sRGB → D50 XYZ (Bradford-adapted, as in the ICC sRGB profile).
|
||||
const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.436_074, 0.385_064, 0.143_080], [0.222_504, 0.716_878, 0.060_618], [0.013_932, 0.097_104, 0.714_173]];
|
||||
/// D50 XYZ → linear sRGB.
|
||||
const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.133_856, -1.616_867, -0.490_615], [-0.978_768, 1.916_142, 0.033_454], [0.071_945, -0.228_991, 1.405_243]];
|
||||
|
||||
fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
|
||||
m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2])
|
||||
}
|
||||
|
||||
fn to_linear(v: f32) -> f32 {
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
fn to_encoded(v: f32) -> f32 {
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
/// Normalized Lab → encoded sRGB in 0..1 (out-of-gamut colours clipped).
|
||||
pub(crate) fn lab_to_srgb(n: [f32; 3]) -> [f32; 3] {
|
||||
let (l, a, b) = (n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0);
|
||||
let fy = (l + 16.0) / 116.0;
|
||||
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
|
||||
let finv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) };
|
||||
let xyz = [finv(fx) * D50[0], finv(fy) * D50[1], finv(fz) * D50[2]];
|
||||
mul(&XYZ_D50_TO_SRGB, xyz).map(|v| to_encoded(v.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// Encoded sRGB → normalized Lab.
|
||||
pub(crate) fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] {
|
||||
let xyz = mul(&SRGB_TO_XYZ_D50, rgb.map(|v| to_linear(v.clamp(0.0, 1.0))));
|
||||
let f = |t: f32| if t > (6.0f32 / 29.0).powi(3) { t.cbrt() } else { t / (3.0 * (6.0f32 / 29.0).powi(2)) + 4.0 / 29.0 };
|
||||
let (fx, fy, fz) = (f(xyz[0] / D50[0]), f(xyz[1] / D50[1]), f(xyz[2] / D50[2]));
|
||||
let (l, a, b) = (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz));
|
||||
[(l / 100.0).clamp(0.0, 1.0), ((a + 128.0) / 255.0).clamp(0.0, 1.0), ((b + 128.0) / 255.0).clamp(0.0, 1.0)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn white_black_and_round_trips() {
|
||||
let w = srgb_to_lab([1.0; 3]);
|
||||
assert!((w[0] - 1.0).abs() < 1e-3 && (w[1] - 128.0 / 255.0).abs() < 1e-3 && (w[2] - 128.0 / 255.0).abs() < 1e-3, "{w:?}");
|
||||
assert!(srgb_to_lab([0.0; 3])[0].abs() < 1e-4);
|
||||
for rgb in [[0.2, 0.5, 0.8], [0.9, 0.1, 0.3], [0.5; 3]] {
|
||||
let back = lab_to_srgb(srgb_to_lab(rgb));
|
||||
assert!(rgb.iter().zip(back).all(|(a, b)| (a - b).abs() < 2e-3), "{rgb:?} → {back:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ pub mod exif_color;
|
|||
mod fidelity;
|
||||
mod format;
|
||||
mod image;
|
||||
mod lab;
|
||||
mod options;
|
||||
pub mod orientation;
|
||||
pub mod resolution;
|
||||
|
|
@ -79,6 +80,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
|||
Format::Dds => dds::decode(bytes, l),
|
||||
Format::Fits => fits::decode(bytes, l),
|
||||
Format::Dpx => dpx::decode(bytes, l),
|
||||
Format::Cineon => dpx::decode_cineon(bytes, l),
|
||||
Format::OpenExr => exr::decode(bytes, l),
|
||||
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
|
||||
|
|
@ -97,11 +99,33 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
|||
Format::Tiff => tiff::orientation(bytes, None),
|
||||
Format::Heif => 1,
|
||||
Format::Dpx => dpx::orientation(bytes),
|
||||
Format::Cineon => dpx::cineon_orientation(bytes),
|
||||
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
|
||||
};
|
||||
finish_decode(img, o, opts)
|
||||
}
|
||||
|
||||
/// The upright pixel size [`decode`] would return, read from the file's header without decoding
|
||||
/// pixels, for WebP, OpenEXR (flat) and the GIF/BMP/TGA/ICO/QOI/HDR family. It runs the same
|
||||
/// header reads and size checks as the decoder (default [`Limits`]) and swaps the size for an
|
||||
/// EXIF orientation of 5–8, so wherever [`decode`] succeeds the two agree. Other formats (and
|
||||
/// deep EXR or AVIF) return [`CodecError::Unsupported`]: decode those to learn their size.
|
||||
pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> {
|
||||
let format = detect(bytes).ok_or(CodecError::UnknownFormat)?;
|
||||
if !caps(format).read {
|
||||
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
|
||||
}
|
||||
let l = &Limits::default();
|
||||
let (w, h, exif) = match format {
|
||||
Format::WebP => webp::probe(bytes, l)?,
|
||||
Format::OpenExr => exr::probe(bytes, l).map(|(w, h)| (w, h, None))?,
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::probe(format, bytes, l)?,
|
||||
_ => return Err(CodecError::unsupported(format, "no header-only size probe")),
|
||||
};
|
||||
let o = exif.as_deref().map_or(1, exif_orientation);
|
||||
Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) })
|
||||
}
|
||||
|
||||
/// Returns the embedded colour table for an indexed BMP, if it has one.
|
||||
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
via_image::bmp_palette(bytes)
|
||||
|
|
@ -231,6 +255,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
|||
Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
|
||||
Format::Fits => fits::encode(image, plan, opts),
|
||||
Format::Dpx => dpx::encode(image, plan, opts),
|
||||
Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")),
|
||||
Format::OpenExr => exr::encode(image, plan, opts),
|
||||
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
|
||||
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
|
||||
|
|
|
|||
|
|
@ -118,6 +118,23 @@ fn gen_deep(
|
|||
tile: Option<(usize, usize)>,
|
||||
counts: &[u32],
|
||||
sample: &dyn Fn(usize, usize) -> Vec<f32>,
|
||||
) -> Vec<u8> {
|
||||
gen_deep_tables(w, h, channels, compression, tile, counts, sample, false)
|
||||
}
|
||||
|
||||
/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile
|
||||
/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its
|
||||
/// clipped width), then zeros.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn gen_deep_tables(
|
||||
w: usize,
|
||||
h: usize,
|
||||
channels: &[Chan],
|
||||
compression: u8,
|
||||
tile: Option<(usize, usize)>,
|
||||
counts: &[u32],
|
||||
sample: &dyn Fn(usize, usize) -> Vec<f32>,
|
||||
full_tile_tables: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0x0131_2f76u32.to_le_bytes());
|
||||
|
|
@ -216,6 +233,10 @@ fn gen_deep(
|
|||
table.extend_from_slice(&acc.to_le_bytes());
|
||||
}
|
||||
}
|
||||
assert_eq!(table.len(), table_len(b));
|
||||
if let (true, Some((tw, th))) = (full_tile_tables, tile) {
|
||||
table.resize(tw * th * 4, 0);
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
for row in b.pixels.chunks(b.row_w) {
|
||||
for (ci, ch) in channels.iter().enumerate() {
|
||||
|
|
@ -237,7 +258,6 @@ fn gen_deep(
|
|||
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
|
||||
c.extend_from_slice(&ct);
|
||||
c.extend_from_slice(&cd);
|
||||
assert_eq!(table.len(), table_len(b));
|
||||
assert_eq!(data.len(), data_len(b));
|
||||
chunks.push((bi, c));
|
||||
}
|
||||
|
|
@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() {
|
|||
assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. })));
|
||||
}
|
||||
|
||||
/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes
|
||||
/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4
|
||||
/// image, its table stored at that size, makes a compressed file of tens of KB whose table
|
||||
/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a
|
||||
/// budget smaller than both the table must be refused before it is allocated; with room for
|
||||
/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of
|
||||
/// ordinary tiles, still decode under a small budget.
|
||||
#[test]
|
||||
fn deep_tile_count_table_is_admitted_against_max_alloc() {
|
||||
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
|
||||
let tight = budget(1 << 20);
|
||||
let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes
|
||||
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
|
||||
for compression in [0u8, 1, 2] {
|
||||
let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true);
|
||||
assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}");
|
||||
let got = decode_deep_exr(&big, &tight);
|
||||
assert!(
|
||||
matches!(got, Err(CodecError::LimitExceeded(_))),
|
||||
"compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}",
|
||||
got.map(|d| d.total_samples())
|
||||
);
|
||||
let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() });
|
||||
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width()));
|
||||
// The same tile with its table stored at the clipped size fits the small budget.
|
||||
let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba);
|
||||
assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}");
|
||||
if compression != 0 {
|
||||
assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len());
|
||||
}
|
||||
// The table and its scratch copy are admitted together: 8 MiB.
|
||||
assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}");
|
||||
let got = decode_deep_exr(&big, &budget(2 * table - 1));
|
||||
assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples()));
|
||||
}
|
||||
// Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones.
|
||||
let counts: Vec<u32> = (0..35).map(|p| (p * 3) % 4).collect();
|
||||
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() };
|
||||
for compression in [0u8, 1, 2] {
|
||||
let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true);
|
||||
let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample);
|
||||
assert_eq!(
|
||||
decode_deep_exr(&full, &tight).expect("full-size edge tables"),
|
||||
decode_deep_exr(&clipped, &tight).expect("clipped edge tables"),
|
||||
"compression {compression}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side,
|
||||
/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits,
|
||||
/// where its full size passes the check and is only tried as the data arrives. Each tile is
|
||||
/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over
|
||||
/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's
|
||||
/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for
|
||||
/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint
|
||||
/// channels decode after full-size tables like float ones.
|
||||
#[test]
|
||||
fn deep_tile_table_admission_edges() {
|
||||
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
|
||||
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
|
||||
let side = (1 << 30) - 2;
|
||||
for compression in [0u8, 1, 2] {
|
||||
let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba);
|
||||
for limits in [Limits::none(), Limits::default(), budget(1 << 20)] {
|
||||
assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}");
|
||||
}
|
||||
}
|
||||
let counts: Vec<u32> = (0..48).map(|p| (p % 3) as u32).collect();
|
||||
let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c });
|
||||
let [a, b, g, r, z] = RGBAZ;
|
||||
let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }];
|
||||
for channels in [&RGBAZ[..], &half[..], &with_id[..]] {
|
||||
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() };
|
||||
for compression in [0u8, 1, 2] {
|
||||
let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true);
|
||||
let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample);
|
||||
let what = format!("{} channels, compression {compression}", channels.len());
|
||||
let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables");
|
||||
assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-channel data-window check admits only four bytes per pixel, but a compressed count
|
||||
/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback;
|
||||
/// the existing multichannel cases have enough pixel allowance to hide this gap.
|
||||
#[test]
|
||||
fn clipped_count_tables_include_scratch_in_the_budget() {
|
||||
let channels = [Chan { name: "Y", ty: 2 }];
|
||||
let counts = vec![0u32; 64];
|
||||
let sample = |_: usize, _: usize| Vec::new();
|
||||
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
|
||||
for tile in [None, Some((64, 1)), Some((128, 2))] {
|
||||
for compression in [0u8, 1, 2] {
|
||||
let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample);
|
||||
let what = format!("tile {tile:?}, compression {compression}");
|
||||
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
|
||||
let required = if compression == 0 { 256 } else { 512 };
|
||||
assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}");
|
||||
assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}");
|
||||
let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() });
|
||||
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}");
|
||||
if compression == 0 {
|
||||
assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's
|
||||
/// compression tag, decides whether a scratch buffer is needed.
|
||||
#[test]
|
||||
fn raw_count_tables_need_no_scratch_even_with_compression() {
|
||||
let channels = [Chan { name: "Y", ty: 2 }];
|
||||
let limits = Limits { max_alloc: 4, ..Limits::none() };
|
||||
for tile in [None, Some((1, 1)), Some((2, 2))] {
|
||||
for compression in [0u8, 1, 2] {
|
||||
let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new());
|
||||
let header_end = header_end_of(&bytes);
|
||||
let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize;
|
||||
let table_size = chunk + if tile.is_some() { 16 } else { 4 };
|
||||
let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size"));
|
||||
assert_eq!(packed, 4, "the four-byte count table is stored raw");
|
||||
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
|
||||
assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the attributes like the format prescribes: the offset table starts after the
|
||||
/// header's terminating zero byte.
|
||||
fn header_end_of(bytes: &[u8]) -> usize {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,17 @@ fn detect_magic_hdr() {
|
|||
assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr));
|
||||
}
|
||||
#[test]
|
||||
fn detect_magic_cineon() {
|
||||
let mut b = vec![0u8; 208];
|
||||
b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]);
|
||||
assert_eq!(detect(&b), Some(Format::Cineon));
|
||||
assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon));
|
||||
assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only");
|
||||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon));
|
||||
// A bare magic is an error, never a panic.
|
||||
assert!(decode_as(Format::Cineon, &b).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn detect_magic_avif() {
|
||||
assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif));
|
||||
assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif));
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ fn png_u16_rgba_is_clean() {
|
|||
fn tiff_is_clean_for_all_integer_and_f32_layouts() {
|
||||
for l in ChannelLayout::ALL {
|
||||
for s in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::F16] {
|
||||
assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}");
|
||||
let w = warns(&test_image(l, s), Format::Tiff);
|
||||
if l.is_lab() && s.is_float() {
|
||||
// CIELab has no float form: it is written with 16 bits, and says so.
|
||||
assert_eq!(w, vec![FidelityWarning::DepthReduced { from: s, to: SampleType::U16 }], "{l:?} {s:?}");
|
||||
} else {
|
||||
assert!(w.is_empty(), "{l:?} {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +121,12 @@ fn in_range_float_not_clipped() {
|
|||
assert!(!w.contains(&W::RangeClipped));
|
||||
}
|
||||
#[test]
|
||||
fn hdr_float_to_fits_clips() {
|
||||
let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap();
|
||||
assert!(warns(&img, Format::Fits).contains(&W::RangeClipped));
|
||||
assert!(!warns(&test_image(ChannelLayout::Gray, SampleType::F32), Format::Fits).contains(&W::RangeClipped));
|
||||
}
|
||||
#[test]
|
||||
fn float_to_exr_and_tiff_is_clean() {
|
||||
for f in [Format::OpenExr, Format::Tiff, Format::Pnm] {
|
||||
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}");
|
||||
|
|
@ -172,6 +184,7 @@ fn every_warning_has_a_message() {
|
|||
W::AlphaDiscarded,
|
||||
W::AlphaBinarized,
|
||||
W::CmykConverted { to: ChannelLayout::Rgb },
|
||||
W::LabConverted { to: ChannelLayout::Rgb },
|
||||
W::ColorToGray,
|
||||
W::PaletteQuantized,
|
||||
W::LossyCompression,
|
||||
|
|
@ -203,6 +216,8 @@ fn warnings_match_actual_roundtrip_behaviour() {
|
|||
assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}");
|
||||
let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk();
|
||||
assert_eq!(w.iter().any(|w| matches!(w, W::CmykConverted { .. })), cmyk_lost, "{f:?} {l:?} {s:?}");
|
||||
let lab_lost = l.is_lab() && !back.layout().is_lab();
|
||||
assert_eq!(w.iter().any(|w| matches!(w, W::LabConverted { .. })), lab_lost, "{f:?} {l:?} {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ fn webp_opens_upright() {
|
|||
// WebP has no gray mode: it comes back as RGB.
|
||||
let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8);
|
||||
assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}");
|
||||
assert_eq!(probe_size(&out).unwrap(), (w as u32, h as u32), "the header probe turns upright too, o={o}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -554,3 +555,24 @@ fn long_orientation_reads_and_rewrites_in_both_byte_orders() {
|
|||
assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `probe_size` reads the size `decode` returns from the header alone, for every format it
|
||||
/// covers, and a file cut short inside its header is refused rather than given a size.
|
||||
#[test]
|
||||
fn probe_size_matches_decode() {
|
||||
let img = synth(7, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.0);
|
||||
let probed = [Format::WebP, Format::Gif, Format::Bmp, Format::Tga, Format::Qoi, Format::Hdr, Format::Ico, Format::OpenExr];
|
||||
assert!(probed.iter().all(|f| rw_formats().contains(f)));
|
||||
for format in rw_formats() {
|
||||
let bytes = encode(&img, format, &EncodeOptions::default()).unwrap();
|
||||
let decoded = decode(&bytes).unwrap().dimensions();
|
||||
match probe_size(&bytes) {
|
||||
Ok(size) => assert_eq!(size, decoded, "{format:?}"),
|
||||
Err(_) => assert!(!probed.contains(&format), "{format:?} should be probed"),
|
||||
}
|
||||
if probed.contains(&format) {
|
||||
assert!(probe_size(&bytes[..10]).is_err(), "{format:?} cut inside its header");
|
||||
}
|
||||
}
|
||||
assert!(probe_size(&[]).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,14 @@ fn tiff_all_compressions_lossless() {
|
|||
for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
let opts = EncodeOptions { tiff_compression: comp, ..Default::default() };
|
||||
let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap();
|
||||
if layout.is_lab() && sample != SampleType::U8 {
|
||||
// CIELab holds a* and b* as 256·a at 16 bits and has no float form: 16-bit and
|
||||
// float Lab come back within one 16-bit step (the PSD's 16-bit Lab does the same).
|
||||
let (a, b) = (img.to_normalized(), back.convert(layout, sample).to_normalized());
|
||||
let worst = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst <= 1.5 / 65535.0, "{layout:?} {sample:?} {comp:?}: {worst}");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(back, img.clone().with_meta(back.meta.clone()), "{layout:?} {sample:?} {comp:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
crates/codecs/tests/tiff_lab.rs
Normal file
76
crates/codecs/tests/tiff_lab.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! CIE L*a*b* TIFFs: CIELab (PhotometricInterpretation 8) as Photoshop writes it, with a* and b*
|
||||
//! signed, and ICCLab (9) with them offset, both read into the Lab layout (a* and b* as
|
||||
//! `(a + 128) / 255`).
|
||||
|
||||
use photocraft_codecs::*;
|
||||
|
||||
/// One Lab pixel in the codec's normalized form.
|
||||
fn lab(l: f32, a: f32, b: f32) -> [f32; 3] {
|
||||
[l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0]
|
||||
}
|
||||
|
||||
fn image(sample: SampleType) -> Image {
|
||||
let px: Vec<f32> = [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].concat();
|
||||
Image::from_normalized(2, 1, ChannelLayout::Lab, sample, &px).unwrap()
|
||||
}
|
||||
|
||||
fn uncompressed(img: &Image) -> Vec<u8> {
|
||||
encode(img, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::None, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_bit_cielab_holds_a_and_b_signed() {
|
||||
let file = uncompressed(&image(SampleType::U8));
|
||||
// L* 50 → 128, a* 50, b* −30 as a signed byte; then white with neutral a* and b*.
|
||||
assert!(find(&file, &[128, 50, (-30i8) as u8, 255, 0, 0]).is_some(), "CIELab samples");
|
||||
let back = decode(&file).unwrap();
|
||||
assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U8));
|
||||
assert_eq!(back.data(), image(SampleType::U8).data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_cielab_holds_256_a() {
|
||||
let file = uncompressed(&image(SampleType::U16));
|
||||
let le = &file[..2] == b"II";
|
||||
let u16b = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() };
|
||||
// a* 50 → 12800, b* −30 → −7680, white's a* and b* 0.
|
||||
let want: Vec<u8> = [32768u16, 12800, (-7680i16) as u16, 65535, 0, 0].iter().flat_map(|v| u16b(*v)).collect();
|
||||
assert!(find(&file, &want).is_some(), "CIELab samples");
|
||||
let back = decode(&file).unwrap();
|
||||
assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U16));
|
||||
let (a, b) = (image(SampleType::U16).to_normalized(), back.to_normalized());
|
||||
assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 1.5 / 65535.0), "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icclab_reads_with_offset_a_and_b() {
|
||||
// The CIELab file turned into ICCLab: PhotometricInterpretation 9, a* and b* + 128.
|
||||
let mut file = uncompressed(&image(SampleType::U8));
|
||||
let at = find(&file, &[128, 50, (-30i8) as u8, 255, 0, 0]).unwrap();
|
||||
for i in [1, 2, 4, 5] {
|
||||
file[at + i] ^= 0x80;
|
||||
}
|
||||
let le = &file[..2] == b"II";
|
||||
let entry: Vec<u8> = if le { [262u16.to_le_bytes(), 3u16.to_le_bytes()].concat() } else { [262u16.to_be_bytes(), 3u16.to_be_bytes()].concat() };
|
||||
let e = find(&file, &entry).expect("PhotometricInterpretation entry");
|
||||
let nine = if le { 9u16.to_le_bytes() } else { 9u16.to_be_bytes() };
|
||||
file[e + 8..e + 10].copy_from_slice(&nine);
|
||||
let back = decode(&file).unwrap();
|
||||
assert_eq!(back.layout(), ChannelLayout::Lab);
|
||||
assert_eq!(back.data(), image(SampleType::U8).data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_converts_to_rgb_for_other_formats() {
|
||||
// White and a saturated orange-red; PNG has no Lab, so the encoder plan converts to RGB.
|
||||
let img = image(SampleType::U8);
|
||||
let png = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
assert!(png.layout().is_rgb());
|
||||
let p = png.to_rgba8();
|
||||
assert_eq!(&p[4..7], &[255, 255, 255], "white");
|
||||
assert!(p[0] > 180 && p[1] < 120 && p[2] > 140, "L 50, a 50, b −30 is a magenta-ish purple: {:?}", &p[..3]);
|
||||
}
|
||||
|
|
@ -79,37 +79,29 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
|||
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>) {
|
||||
match adj {
|
||||
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
|
||||
Adjustment::Threshold { level } => {
|
||||
// Compared on the 8-bit grid like Photoshop (avoids float ties).
|
||||
let t = (level * 255.0).round();
|
||||
map_rgb(buf, |c| {
|
||||
let v = if (rgb_to_gray(c) * 255.0).round() >= t { 1.0 } else { 0.0 };
|
||||
[v; 3]
|
||||
})
|
||||
}
|
||||
Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize(v, *levels))),
|
||||
Adjustment::Threshold { level } => map_rgb(buf, |c| [if threshold_white(c, *level, depth) { 1.0 } else { 0.0 }; 3]),
|
||||
Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize_in(v, *levels, depth))),
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => {
|
||||
// Legacy: contrast scales around 128/255, with brightness added before it when
|
||||
// contrast is raised and after it when contrast is lowered. Measured on Photoshop
|
||||
// 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while
|
||||
// the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first;
|
||||
// contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130).
|
||||
let f = legacy_brightness_contrast(*brightness, *contrast);
|
||||
map_rgb(buf, |px| px.map(&f))
|
||||
if depth == Some(SampleType::F32) {
|
||||
let f = legacy_brightness_contrast_hdr(*brightness, *contrast);
|
||||
map_rgb(buf, |px| px.map(&f));
|
||||
} else {
|
||||
let f = legacy_brightness_contrast(*brightness, *contrast);
|
||||
map_rgb(buf, |px| px.map(&f))
|
||||
}
|
||||
}
|
||||
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
|
||||
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth
|
||||
// (a 0..255 ramp pushed through the real app; see log/devlog.md). Unlike the legacy
|
||||
// linear scale, both are smooth curves that pin pure black and white:
|
||||
// • Brightness: a line of slope s = 1.375^(b/50) from the origin that rolls off to
|
||||
// (1,1) via a `v^P` white-anchor term (P grows as |b| grows).
|
||||
// • Contrast: a symmetric cubic-Hermite S-curve pivoting at 0.5, with endpoint slope
|
||||
// 1 - c/128 and centre slope 1 + c/128 (near-exact: ≤0.5/255 vs Photoshop).
|
||||
// Applied brightness-then-contrast. Exact at the sliders' zero and extremes still drift
|
||||
// on the brightness side (the real curve is a spline); contrast matches closely.
|
||||
let b = *brightness;
|
||||
let c = *contrast;
|
||||
map_rgb(buf, |px| px.map(|v| modern_contrast(modern_brightness(v, b), c).clamp(0.0, 1.0)))
|
||||
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth.
|
||||
// Unlike the legacy linear scale, both are smooth curves that pin pure black and white
|
||||
// (see `modern_brightness` and `modern_contrast`), applied brightness then contrast
|
||||
// through one lookup table.
|
||||
let table = modern_lut(*brightness, *contrast);
|
||||
map_rgb(buf, |px| px.map(|v| lut(&table, v)))
|
||||
}
|
||||
Adjustment::Exposure { exposure, offset, gamma } => {
|
||||
// In linear light: (lin·2^exposure + offset)^(1/gamma), then back
|
||||
|
|
@ -175,6 +167,17 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
|||
let hi = if depth == Some(SampleType::F32) { f32::INFINITY } else { 1.0 };
|
||||
map_rgb(buf, |c| vibrance_px(c, v, s, transfer, hi))
|
||||
}
|
||||
Adjustment::ChannelMixer { matrix, monochrome } if depth == Some(SampleType::F32) => {
|
||||
// 32-bit: each source percentage is cut to 1/1024 (toward zero), the constant counts
|
||||
// 255/512 of its value, and nothing clips (photoshop corpus rgb32 channel-mixer.psd:
|
||||
// pure red, green, blue, black and white all exact).
|
||||
let cut = |v: f32| (v * 1024.0).trunc() / 1024.0;
|
||||
let m = matrix.map(|row| [cut(row[0]), cut(row[1]), cut(row[2]), row[3] * 255.0 / 512.0]);
|
||||
map_rgb(buf, |c| {
|
||||
let mix = |row: &[f32; 4]| row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3];
|
||||
if *monochrome { [mix(&m[0]); 3] } else { [mix(&m[0]), mix(&m[1]), mix(&m[2])] }
|
||||
})
|
||||
}
|
||||
Adjustment::ChannelMixer { matrix, monochrome } => map_rgb(buf, |c| {
|
||||
let mix = |row: &[f32; 4]| (row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]).clamp(0.0, 1.0);
|
||||
if *monochrome { [mix(&matrix[0]); 3] } else { [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] }
|
||||
|
|
@ -184,27 +187,39 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
|||
let m = photo_filter_matrix(*color, *density, *preserve_luminosity && linear_doc);
|
||||
let set_lum = *preserve_luminosity && !linear_doc;
|
||||
map_rgb(buf, |c| {
|
||||
let f = mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v));
|
||||
// 32-bit samples are linear already, and may go negative.
|
||||
let f = if linear_doc { mat3_mul(&m, c) } else { mat3_mul(&m, c.map(|v| transfer.decode(v))).map(|v| transfer.encode(v)) };
|
||||
let f = if set_lum { photocraft_color::blend::set_lum(f, photocraft_color::blend::lum(c)) } else { f };
|
||||
f.map(|v| v.clamp(0.0, 1.0))
|
||||
// 32-bit results aren't clipped (photoshop corpus rgb32 photo-filter.psd: white
|
||||
// filters to 1.19 red).
|
||||
if linear_doc { f } else { f.map(|v| v.clamp(0.0, 1.0)) }
|
||||
})
|
||||
}
|
||||
Adjustment::BlackWhite { weights, tint } => {
|
||||
let hdr = depth == Some(SampleType::F32);
|
||||
map_rgb(buf, |c| {
|
||||
let g = black_white_gray(c, weights);
|
||||
match tint {
|
||||
// Photoshop tints like the Color blend mode: the tint's hue and saturation at
|
||||
// the grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. In
|
||||
// 32-bit documents nothing clips at 1, as with the blend mode there (photoshop
|
||||
// corpus rgb32 black-white-tint.psd: white tints to 1.05, 1.00, 0.87).
|
||||
Some(t) if hdr => photocraft_color::blend::blend_rgb_within(photocraft_color::blend::BlendMode::Color, [g; 3], *t, f32::MAX),
|
||||
Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)),
|
||||
None => [g; 3],
|
||||
}
|
||||
})
|
||||
}
|
||||
Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| {
|
||||
let g = black_white_gray(c, weights);
|
||||
match tint {
|
||||
// Photoshop tints like the Color blend mode: the tint's hue and saturation at the
|
||||
// grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11.
|
||||
Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)),
|
||||
None => [g; 3],
|
||||
}
|
||||
}),
|
||||
Adjustment::GradientMap { stops, reverse, dither } => {
|
||||
let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
|
||||
for (i, p) in buf.px.iter_mut().enumerate() {
|
||||
if p[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut t = rgb_to_gray([p[0], p[1], p[2]]);
|
||||
// Driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: photoshop
|
||||
// corpus rgb8 and rgb16 gradient-map.psd match within a level (2 levels off with
|
||||
// 0.299, 0.587, 0.114).
|
||||
let mut t = photocraft_color::blend::lum([p[0], p[1], p[2]]);
|
||||
if *reverse {
|
||||
t = 1.0 - t;
|
||||
}
|
||||
|
|
@ -239,13 +254,55 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
|||
}
|
||||
}
|
||||
|
||||
/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]).
|
||||
fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
|
||||
/// Legacy Brightness/Contrast on a 32-bit document's samples: unclipped, with contrast pivoting at
|
||||
/// 127 of 255 (photoshop corpus rgb32 and gray32 brightness-contrast-legacy.psd: every pixel
|
||||
/// exact, negative and over-white values included; the 8/16-bit pivot, 128, is 0.43 of a level
|
||||
/// off there).
|
||||
fn legacy_brightness_contrast_hdr(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
|
||||
const PIVOT: f32 = 127.0 / 255.0;
|
||||
let b = brightness / 255.0;
|
||||
let c = contrast.clamp(-100.0, 99.0);
|
||||
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
|
||||
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
|
||||
move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)
|
||||
move |v: f32| (v + pre - PIVOT) * k + PIVOT + post
|
||||
}
|
||||
|
||||
/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]).
|
||||
fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
|
||||
legacy_brightness_contrast_at(brightness, contrast, LEGACY_PIVOT)
|
||||
}
|
||||
|
||||
/// [`legacy_brightness_contrast`] with contrast pivoting at `pivot`.
|
||||
fn legacy_brightness_contrast_at(brightness: f32, contrast: f32, pivot: f32) -> impl Fn(f32) -> f32 {
|
||||
let b = brightness / 255.0;
|
||||
let c = contrast.clamp(-100.0, 99.0);
|
||||
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
|
||||
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
|
||||
move |v: f32| ((v + pre - pivot) * k + pivot + post).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Whether Threshold at `level` turns colour `c` white in a document of `depth` (`None`: unknown,
|
||||
/// treated as 8-bit). Photoshop splits on the luminosity 0.30 R + 0.59 G + 0.11 B (the blend
|
||||
/// modes' `lum`), not the Rec. 601 grey, of the document's whole sample values:
|
||||
/// - 8 bits: white when that luminosity, rounded to a level with halves down, reaches the level;
|
||||
/// - 16 bits: white when it reaches the level converted to 16 bits (128 → 16448);
|
||||
/// - 32 bits: white when it reaches the level.
|
||||
///
|
||||
/// Exact on every pixel of psd-tools threshold_rgb.psd, threshold_16bits_rgb.psd and
|
||||
/// threshold_16bits_grayscale.psd and of the photoshop corpus's rgb16 threshold-128.psd, where the
|
||||
/// Rec. 601 grey split 28 and 100 pixels of the first two the other way. The sums of
|
||||
/// `30 R + 59 G + 11 B` over whole samples are exact in f32 (at most 3 276 800).
|
||||
pub fn threshold_white(c: [f32; 3], level: f32, depth: Option<SampleType>) -> bool {
|
||||
let t = (level * 255.0).round();
|
||||
let sum = |q: f32| {
|
||||
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * q).round());
|
||||
30.0 * r + 59.0 * g + 11.0 * b
|
||||
};
|
||||
match depth {
|
||||
None | Some(SampleType::U8) => sum(255.0) > 100.0 * t - 50.0,
|
||||
Some(SampleType::U16) => sum(32768.0) >= 100.0 * (t * 32768.0 / 255.0).round(),
|
||||
Some(SampleType::F32) => (0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]) * 255.0 >= t,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab
|
||||
|
|
@ -256,11 +313,25 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O
|
|||
apply_lab(adj, buf, depth);
|
||||
} else if mode == ColorMode::Cmyk && cmyk_channel_adjustment(adj) {
|
||||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||||
map_rgb(buf, |c| {
|
||||
let mut ink = rgb_to_cmyk(c);
|
||||
stored_channels(adj, mode, depth, &mut ink, rgb_to_gray(c));
|
||||
cmyk_to_rgb(ink)
|
||||
});
|
||||
if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj {
|
||||
let table = ink_brightness_contrast_lut(*brightness, *contrast, *legacy);
|
||||
map_rgb(buf, |c| cmyk_to_rgb(rgb_to_cmyk(c).map(|v| ink_through(&table, v))));
|
||||
} else if cmy_adjustment(adj) {
|
||||
let mut inks: Vec<[f32; 4]> = buf.px.iter().map(|p| rgb_to_cmyk([p[0], p[1], p[2]])).collect();
|
||||
apply_cmy(adj, &mut inks);
|
||||
for (p, ink) in buf.px.iter_mut().zip(&inks) {
|
||||
if p[3] > 0.0 {
|
||||
let [r, g, b] = cmyk_to_rgb(*ink);
|
||||
(p[0], p[1], p[2]) = (r, g, b);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
map_rgb(buf, |c| {
|
||||
let mut ink = rgb_to_cmyk(c);
|
||||
stored_channels(adj, mode, depth, &mut ink);
|
||||
cmyk_to_rgb(ink)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
apply_depth(adj, buf, transfer, depth);
|
||||
}
|
||||
|
|
@ -282,13 +353,55 @@ pub fn lab_channel_adjustment(adj: &Adjustment) -> bool {
|
|||
}
|
||||
|
||||
/// Whether a CMYK document evaluates `adj` on its inks rather than on the screen colour: Invert
|
||||
/// and Posterize (each ink, black included, so paper inverts to 400% ink) and Threshold (paper or
|
||||
/// 100% black ink alone). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4 and threshold-128
|
||||
/// follow these rules on the files' own inks on every pixel; here the inks are the screen colour
|
||||
/// separated through the document's CMYK profile, so neutrals carry the black that profile
|
||||
/// generates rather than the file's.
|
||||
/// and Posterize (each ink, black included, so paper inverts to 400% ink), Threshold (paper or
|
||||
/// 100% black ink alone) and Brightness/Contrast (each ink's stored value, 1 - ink, through the
|
||||
/// tone curve; see [`ink_through`]). Photoshop corpus cmyk8 and cmyk16 invert, posterize-4,
|
||||
/// threshold-128 and brightness-contrast(-legacy) follow these rules on the files' own inks on
|
||||
/// every pixel; here the inks are the screen colour separated through the document's CMYK
|
||||
/// profile, so neutrals carry the black that profile generates rather than the file's.
|
||||
pub fn cmyk_channel_adjustment(adj: &Adjustment) -> bool {
|
||||
stored_channel_adjustment(adj)
|
||||
stored_channel_adjustment(adj) || cmy_adjustment(adj) || matches!(adj, Adjustment::BrightnessContrast { .. })
|
||||
}
|
||||
|
||||
/// Whether a CMYK document evaluates `adj` on C, M and Y as the complement of an RGB colour,
|
||||
/// black kept ([`apply_cmy`]): Hue/Saturation (not Colorize, which Photoshop separates again,
|
||||
/// black and all) and Color Balance (photoshop corpus cmyk16 hue-saturation.psd follows this on
|
||||
/// every pixel within half a level, cmyk8 within two; color-balance within our RGB Color
|
||||
/// Balance's own error).
|
||||
pub fn cmy_adjustment(adj: &Adjustment) -> bool {
|
||||
matches!(adj, Adjustment::HueSaturation { colorize: false, .. } | Adjustment::ColorBalance { .. })
|
||||
}
|
||||
|
||||
/// A [`cmy_adjustment`] on straight inks (C, M, Y, K; 0 none): the RGB adjustment on
|
||||
/// `(1 - c, 1 - m, 1 - y)`, black left alone.
|
||||
pub fn apply_cmy(adj: &Adjustment, inks: &mut [[f32; 4]]) {
|
||||
let width = i32::try_from(inks.len()).unwrap_or(i32::MAX);
|
||||
let mut buf = Buffer { rect: crate::Rect::new(0, 0, width, 1), px: inks.iter().map(|i| [1.0 - i[0], 1.0 - i[1], 1.0 - i[2], 1.0]).collect() };
|
||||
apply_depth(adj, &mut buf, Transfer::Srgb, None);
|
||||
for (ink, p) in inks.iter_mut().zip(&buf.px) {
|
||||
for c in 0..3 {
|
||||
ink[c] = (1.0 - p[c]).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brightness/Contrast as the lookup table (4096 samples over 0..1) a CMYK ink's stored value,
|
||||
/// `1 - ink`, goes through ([`ink_through`]): [`modern_lut`], or the legacy linear curve with its
|
||||
/// contrast pivoting at 128 in ink, which is 127 of 255 stored (photoshop corpus cmyk16
|
||||
/// brightness-contrast-legacy.psd sits 0.43 of a level off around 128 stored, as a one-level
|
||||
/// pivot shift times contrast +30's slope predicts).
|
||||
pub fn ink_brightness_contrast_lut(brightness: f32, contrast: f32, legacy: bool) -> Vec<f32> {
|
||||
if !legacy {
|
||||
return modern_lut(brightness, contrast);
|
||||
}
|
||||
let curve = legacy_brightness_contrast_at(brightness, contrast, 1.0 - LEGACY_PIVOT);
|
||||
(0..LUT_SIZE).map(|k| curve(k as f32 / (LUT_SIZE - 1) as f32)).collect()
|
||||
}
|
||||
|
||||
/// One CMYK ink (0: none) through a channel tone curve the way Photoshop applies one in CMYK: to
|
||||
/// the stored value, `1 - ink`, so raising brightness takes ink away.
|
||||
pub fn ink_through(table: &[f32], ink: f32) -> f32 {
|
||||
1.0 - lut(table, 1.0 - ink)
|
||||
}
|
||||
|
||||
/// Whether [`stored_channels`] evaluates `adj`: Invert, Posterize and Threshold.
|
||||
|
|
@ -298,10 +411,10 @@ pub fn stored_channel_adjustment(adj: &Adjustment) -> bool {
|
|||
|
||||
/// Invert, Posterize or Threshold on one pixel's own channels in a Lab or CMYK document of
|
||||
/// `depth` (`None`: unknown, treated as 8-bit): `ch` holds L, a, b normalised as [`lab_norm`]
|
||||
/// gives them, or the C, M, Y, K inks (0: none); `gray` is the pixel's luma, which CMYK Threshold
|
||||
/// compares. Lab Invert turns 8-bit bytes `v` into `255 - v` (a becomes -1 - a) and mirrors a and
|
||||
/// b around neutral at 16 bits (photoshop corpus lab16 invert.psd: neutral stays neutral).
|
||||
pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleType>, ch: &mut [f32], gray: f32) {
|
||||
/// gives them, or the C, M, Y, K inks (0: none). Lab Invert turns 8-bit bytes `v` into `255 - v`
|
||||
/// (a becomes -1 - a) and mirrors a and b around neutral at 16 bits (photoshop corpus lab16
|
||||
/// invert.psd: neutral stays neutral). CMYK Threshold compares [`ink_luma`].
|
||||
pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleType>, ch: &mut [f32]) {
|
||||
let lab = mode == ColorMode::Lab;
|
||||
match adj {
|
||||
Adjustment::Invert => {
|
||||
|
|
@ -310,7 +423,10 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleTy
|
|||
*v = if i > 0 { mirror } else { 1.0 } - v.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize(*v, *levels)),
|
||||
// CMYK posterizes the stored values (1 - ink), which its uneven levels make differ from
|
||||
// posterizing the ink: psd-tools posterize_cmyk.psd (3 to 21 levels) is exact so.
|
||||
Adjustment::Posterize { levels } if mode == ColorMode::Cmyk => ch.iter_mut().for_each(|v| *v = 1.0 - posterize_in(1.0 - *v, *levels, depth)),
|
||||
Adjustment::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize_in(*v, *levels, depth)),
|
||||
Adjustment::Threshold { level } => {
|
||||
// On the 8-bit grid, like the RGB threshold.
|
||||
let t = (level * 255.0).round();
|
||||
|
|
@ -318,7 +434,9 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleTy
|
|||
let white = ch.first().is_some_and(|l| (l.clamp(0.0, 1.0) * 255.0).round() >= t);
|
||||
[if white { 1.0 } else { 0.0 }, 128.0 / 255.0, 128.0 / 255.0, 0.0]
|
||||
} else {
|
||||
[0.0, 0.0, 0.0, if (gray * 255.0).round() < t { 1.0 } else { 0.0 }]
|
||||
let ink = |i: usize| ch.get(i).copied().unwrap_or(0.0);
|
||||
let black = (ink_luma([ink(0), ink(1), ink(2), ink(3)]) * 255.0).round() < t;
|
||||
[0.0, 0.0, 0.0, if black { 1.0 } else { 0.0 }]
|
||||
};
|
||||
ch.iter_mut().zip(out).for_each(|(v, o)| *v = o);
|
||||
}
|
||||
|
|
@ -326,6 +444,14 @@ pub fn stored_channels(adj: &Adjustment, mode: ColorMode, depth: Option<SampleTy
|
|||
}
|
||||
}
|
||||
|
||||
/// The luma Photoshop's CMYK Threshold compares: Rec. 601 weights on the inks' plain complement,
|
||||
/// `(1 - c)(1 - k)` and so on, whatever the document's profile (photoshop corpus cmyk8 and cmyk16
|
||||
/// threshold-128.psd: every pixel on the right side of level 128).
|
||||
pub fn ink_luma(ink: [f32; 4]) -> f32 {
|
||||
let paper = 1.0 - ink[3].clamp(0.0, 1.0);
|
||||
rgb_to_gray([0, 1, 2].map(|i| (1.0 - ink[i].clamp(0.0, 1.0)) * paper))
|
||||
}
|
||||
|
||||
/// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255,
|
||||
/// (b+128)/255.
|
||||
fn lab_norm(c: [f32; 3]) -> [f32; 3] {
|
||||
|
|
@ -338,6 +464,49 @@ fn lab_denorm(n: [f32; 3]) -> [f32; 3] {
|
|||
lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
|
||||
}
|
||||
|
||||
/// Neutral a or b as Lab surfaces store it ((0 + 128) / 255).
|
||||
pub const LAB_NEUTRAL: f32 = 128.0 / 255.0;
|
||||
|
||||
/// An adjustment Photoshop evaluates on a Lab document's own channels, as an operation on one
|
||||
/// pixel's stored L, a, b (normalised as Lab surfaces store them: L / 100, (a + 128) / 255,
|
||||
/// (b + 128) / 255) in a document of `depth`: Invert and Posterize (each channel), Threshold
|
||||
/// (split on L, neutral result), Brightness/Contrast (L) and Levels or Curves with Lab records.
|
||||
/// `None` for any other adjustment. On the stored values colours outside the display gamut keep
|
||||
/// theirs, where [`apply_in`] can only round-trip them through clipped display RGB.
|
||||
pub fn lab_stored_op(adj: &Adjustment, depth: Option<SampleType>) -> Option<LabOp<'_>> {
|
||||
let op: LabOp<'_> = match adj {
|
||||
Adjustment::Invert | Adjustment::Posterize { .. } => Box::new(move |ch: &mut [f32; 3]| stored_channels(adj, ColorMode::Lab, depth, ch)),
|
||||
Adjustment::Threshold { level } => {
|
||||
// Split on L on the 8-bit grid, like the display path (`apply_lab`).
|
||||
let t = (level * 255.0).round();
|
||||
Box::new(move |ch: &mut [f32; 3]| {
|
||||
let white = (ch[0].clamp(0.0, 1.0) * 255.0).round() >= t;
|
||||
*ch = [if white { 1.0 } else { 0.0 }, LAB_NEUTRAL, LAB_NEUTRAL];
|
||||
})
|
||||
}
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
|
||||
let (b, c, legacy) = (*brightness, *contrast, *legacy);
|
||||
let legacy_curve = legacy_brightness_contrast(b, c);
|
||||
Box::new(move |ch: &mut [f32; 3]| {
|
||||
ch[0] = if legacy { legacy_curve(ch[0]) } else { modern_contrast(modern_brightness(ch[0], b), c).clamp(0.0, 1.0) };
|
||||
})
|
||||
}
|
||||
Adjustment::Levels { space: ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Lab, .. } => {
|
||||
let luts = tone_luts_depth(adj, depth);
|
||||
Box::new(move |ch: &mut [f32; 3]| {
|
||||
for (v, t) in ch.iter_mut().zip(&luts) {
|
||||
*v = lut(t, *v);
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(op)
|
||||
}
|
||||
|
||||
/// One pixel's stored L, a, b through an adjustment ([`lab_stored_op`]).
|
||||
pub type LabOp<'a> = Box<dyn Fn(&mut [f32; 3]) + Send + Sync + 'a>;
|
||||
|
||||
/// The Lab evaluation of a [`lab_channel_adjustment`] in a document of `depth` (no-op for any
|
||||
/// other adjustment).
|
||||
fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
|
||||
|
|
@ -345,7 +514,7 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
|
|||
// L → 100 - L and a, b mirrored about neutral; each channel posterized (see `stored_channels`).
|
||||
Adjustment::Invert | Adjustment::Posterize { .. } => map_rgb(buf, |c| {
|
||||
let mut n = lab_norm(c);
|
||||
stored_channels(adj, ColorMode::Lab, depth, &mut n, 0.0);
|
||||
stored_channels(adj, ColorMode::Lab, depth, &mut n);
|
||||
lab_denorm(n)
|
||||
}),
|
||||
Adjustment::Threshold { level } => {
|
||||
|
|
@ -355,7 +524,11 @@ fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<SampleType>) {
|
|||
}
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
|
||||
let legacy_curve = legacy_brightness_contrast(*brightness, *contrast);
|
||||
let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) };
|
||||
let modern = (!*legacy).then(|| modern_lut(*brightness, *contrast));
|
||||
let curve = |v: f32| match &modern {
|
||||
Some(table) => lut(table, v),
|
||||
None => legacy_curve(v),
|
||||
};
|
||||
// The tone curve on L only. Added as the difference of two round trips (like
|
||||
// `tone_in_space`) so a and b, and colours a round trip moves, stay put.
|
||||
map_rgb(buf, |c| {
|
||||
|
|
@ -591,61 +764,63 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f
|
|||
}
|
||||
}
|
||||
|
||||
/// How far one Color Balance slider at ±100 moves the black or white point (Photoshop 27.11:
|
||||
/// shadows −100 maps 99.6 of 255 to black, highlights +100 maps 155.5 to white).
|
||||
const BALANCE_LEVELS: f32 = 0.39;
|
||||
|
||||
/// Color Balance on channel `ch` (0 red, 1 green, 2 blue) of `v` (0..1); `tones` are the
|
||||
/// shadows, midtones and highlights slider triples (cyan–red, magenta–green, yellow–blue,
|
||||
/// −100..100).
|
||||
///
|
||||
/// Reverse-engineered from Photoshop 27.11 (a gray ramp and pure colours through each slider at
|
||||
/// ±25, ±50 and ±100, with Preserve Luminosity on and off): every slider is a levels-and-gamma
|
||||
/// curve per channel, exact within 1 level for a single slider. With `a` = slider / 100:
|
||||
/// - Preserve Luminosity off: a slider only moves its own channel. Midtones is gamma 2^−a.
|
||||
/// Shadows is gamma 2^(−a/2) after a black point at 0.39·|a| when lowered; highlights is
|
||||
/// gamma 2^(−a/2) after a white point at 1 − 0.39·a when raised.
|
||||
/// - Preserve Luminosity on: midtones uses gamma 2^((max(a) + min(a))/2 − a_ch),
|
||||
/// preserving the one-slider behaviour without summing offsets when multiple sliders move.
|
||||
/// Shadows and highlights go by the differences between a tone's three sliders:
|
||||
/// each channel gets a plain black point at 0.39·(max − its own) for shadows and a plain white
|
||||
/// point at 1 − 0.39·(its own − min) for highlights (so one raised shadows slider darkens the
|
||||
/// other two channels, and two raised together darken only the third).
|
||||
/// Reverse-engineered from Photoshop (a 16-bit gray ramp through every tone at ±10..±100, pairs of
|
||||
/// tones, and random settings of all nine sliders, Preserve Luminosity on and off): the three
|
||||
/// tones fold into one Levels per channel (input black point, input white point, midtone gamma),
|
||||
/// within 0.8 of 255 everywhere. Folding matters: a lowered shadows and a raised highlights
|
||||
/// slider cancel each other's gamma, which applying the tones one after another doesn't (#3423).
|
||||
/// - Preserve Luminosity off: a slider only moves its own channel. Lowered shadows set the black
|
||||
/// point to |shadows| levels, raised highlights set the white point to 255 − highlights levels,
|
||||
/// and the gamma is 2^(m / 100) with m = midtones + (shadows + highlights) / 2.
|
||||
/// - Preserve Luminosity on: each channel's black point is (max − own) shadows levels, its white
|
||||
/// point 255 − (own − min) highlights levels, and m = own − (max + min) / 2 midtones.
|
||||
///
|
||||
/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one
|
||||
/// channel: within 1 level).
|
||||
/// A gamma > 1 starts with slope 2^gamma at black ([`balance_gamma`]: up to 18 of 255 brighter
|
||||
/// near black without it). When the black point reaches the white point, input
|
||||
/// at or above the white point is white and the rest black.
|
||||
pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 {
|
||||
let [shadows, midtones, highlights] = tones;
|
||||
let black = |v: f32, b: f32| ((v - b) / (1.0 - b)).max(0.0);
|
||||
let white = |v: f32, w: f32| (v / w).min(1.0);
|
||||
let mut v = v.clamp(0.0, 1.0);
|
||||
for (tone, sliders) in [(2, highlights), (0, shadows), (1, midtones)] {
|
||||
let a: [f32; 3] = sliders.map(|s| (s / 100.0).clamp(-1.0, 1.0));
|
||||
if preserve_luminosity {
|
||||
let own = a.get(ch).copied().unwrap_or(0.0);
|
||||
let (lo, hi) = a.iter().fold((f32::MAX, f32::MIN), |m, x| (m.0.min(*x), m.1.max(*x)));
|
||||
v = match tone {
|
||||
0 => black(v, BALANCE_LEVELS * (hi - own)),
|
||||
1 => v.powf(2f32.powf((hi + lo) / 2.0 - own)),
|
||||
_ => white(v, 1.0 - BALANCE_LEVELS * (own - lo)),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
for (src, slider) in sliders.iter().enumerate() {
|
||||
let a = (slider / 100.0).clamp(-1.0, 1.0);
|
||||
if a == 0.0 || src != ch {
|
||||
continue;
|
||||
}
|
||||
v = match tone {
|
||||
1 => v.powf(2f32.powf(-a)),
|
||||
0 if a < 0.0 => black(v, BALANCE_LEVELS * -a).powf(2f32.powf(-a / 2.0)),
|
||||
0 => v.powf(2f32.powf(-a / 2.0)),
|
||||
2 if a > 0.0 => white(v, 1.0 - BALANCE_LEVELS * a).powf(2f32.powf(-a / 2.0)),
|
||||
_ => v.powf(2f32.powf(-a / 2.0)),
|
||||
};
|
||||
}
|
||||
let [shadows, midtones, highlights] = tones.map(|t| t.map(|s| if s.is_finite() { s.clamp(-100.0, 100.0) } else { 0.0 }));
|
||||
let own = |t: [f32; 3]| t.get(ch).copied().unwrap_or(0.0);
|
||||
let max = |t: [f32; 3]| t[0].max(t[1]).max(t[2]);
|
||||
let min = |t: [f32; 3]| t[0].min(t[1]).min(t[2]);
|
||||
let (black, white, m) = if preserve_luminosity {
|
||||
(max(shadows) - own(shadows), own(highlights) - min(highlights), own(midtones) - (max(midtones) + min(midtones)) / 2.0)
|
||||
} else {
|
||||
((-own(shadows)).max(0.0), own(highlights).max(0.0), own(midtones) + (own(shadows) + own(highlights)) / 2.0)
|
||||
};
|
||||
let (b, w) = (black / 255.0, 1.0 - white / 255.0);
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if v >= w {
|
||||
return 1.0;
|
||||
}
|
||||
v
|
||||
if v <= b {
|
||||
return 0.0;
|
||||
}
|
||||
balance_gamma((v - b) / (w - b), 2f32.powf(m / 100.0))
|
||||
}
|
||||
|
||||
/// The midtone gamma of Color Balance's per-channel Levels (`u` in 0..1): `u^(1/gamma)`, except
|
||||
/// that a gamma > 1 (which would start with infinite slope) starts at slope 2^gamma and joins the
|
||||
/// power curve in a cubic Hermite at twice the input where that line meets it (Photoshop,
|
||||
/// measured on a 16-bit ramp at gamma 1.07..2: within 0.02 of 255).
|
||||
fn balance_gamma(u: f32, gamma: f32) -> f32 {
|
||||
let e = 1.0 / gamma.max(0.01);
|
||||
let power = u.powf(e);
|
||||
if gamma <= 1.0 {
|
||||
return power;
|
||||
}
|
||||
let s0 = 2f32.powf(gamma);
|
||||
let u0 = (2.0 * s0.powf(1.0 / (e - 1.0))).min(1.0);
|
||||
if u0.is_nan() || u0 <= 0.0 || u >= u0 {
|
||||
return power;
|
||||
}
|
||||
let (p, d, t) = (u0.powf(e), e * u0.powf(e - 1.0), u / u0);
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
((t3 - 2.0 * t2 + t) * u0 * s0 + (3.0 * t2 - 2.0 * t3) * p + (t3 - t2) * u0 * d).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans,
|
||||
|
|
@ -823,20 +998,118 @@ fn hsv_hue(c: [f32; 3], mx: f32, mn: f32) -> f32 {
|
|||
h * 60.0
|
||||
}
|
||||
|
||||
/// Photoshop posterize: `n` equal input bins over 0..=255, output levels
|
||||
/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels).
|
||||
/// Modern Brightness curve (one channel, `brightness` in [-150, 150]). A line of slope
|
||||
/// `s = 1.375^(b/50)` from the origin, rolled off to (1, 1) by a `v^P` white-anchor term. Pins pure
|
||||
/// black and white; `b = 0` is the identity. Fit to Photoshop ground truth (see modern B/C above).
|
||||
/// Modern Brightness curve (one channel, `brightness` in [-150, 150]); pins pure black and white,
|
||||
/// and `b = 0` is the identity. Raised, it is a line of slope `s = 1.375^(b/50)` from the origin
|
||||
/// up to a knee at output 126/255, then a cubic Hermite to (1, 1) whose end slope is
|
||||
/// `1 / (1 + 0.084 b)`; past about +110, where no such cubic stays below white, the roll-off is
|
||||
/// `1 - (1 - y0)(1 - u)^α` instead (continuous slope `α` at the knee, flat at white). Lowered, it
|
||||
/// is the inverse of the curve raised by as much.
|
||||
///
|
||||
/// Fit to every Photoshop measurement we have, all within about a level and a half: its ±50
|
||||
/// samples (`modern_brightness_matches_photoshop`), the photoshop corpus's brightness-contrast.psd
|
||||
/// (+25 with contrast +30) and psd-tools' brightnesscontrast_rgb.psd, whose masked layers give
|
||||
/// +23, -31 and -95 (+150 within 6 levels there, where every curve family tried bottoms out).
|
||||
pub fn modern_brightness(v: f32, brightness: f32) -> f32 {
|
||||
if brightness == 0.0 {
|
||||
if brightness == 0.0 || !brightness.is_finite() {
|
||||
return v;
|
||||
}
|
||||
let s = 1.375f32.powf(brightness / 50.0);
|
||||
// Exponent of the white-anchor term grows with |b|; shapes the roll-off toward (1,1).
|
||||
let p = if brightness >= 0.0 { (4.5 - 0.013 * brightness).max(2.0) } else { 5.0 - 0.072 * brightness };
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
(s * v + (1.0 - s) * v.powf(p)).clamp(0.0, 1.0)
|
||||
Brightness::new(brightness).eval(v)
|
||||
}
|
||||
|
||||
/// [`modern_brightness`] for one slider value, its curve's constants worked out once.
|
||||
struct Brightness {
|
||||
raise: bool,
|
||||
s: f32,
|
||||
e: f32,
|
||||
x0: f32,
|
||||
/// The knee's slope over the roll-off's secant (`α` of Fritsch–Carlson); a power roll-off
|
||||
/// replaces the Hermite when α² + β² > 9.
|
||||
alpha: f32,
|
||||
hermite: bool,
|
||||
}
|
||||
|
||||
/// Output level of the Brightness curve's knee.
|
||||
const BRIGHTNESS_KNEE: f32 = 126.0 / 255.0;
|
||||
|
||||
impl Brightness {
|
||||
fn new(brightness: f32) -> Self {
|
||||
let b = brightness.abs();
|
||||
let s = 1.375f32.powf(b / 50.0);
|
||||
let e = 1.0 / (1.0 + 0.084 * b);
|
||||
let x0 = (BRIGHTNESS_KNEE / s).min(1.0);
|
||||
let secant = (1.0 - BRIGHTNESS_KNEE) / (1.0 - x0).max(1e-6);
|
||||
let (alpha, beta) = (s / secant, e / secant);
|
||||
Brightness { raise: brightness > 0.0, s, e, x0, alpha, hermite: alpha * alpha + beta * beta <= 9.0 }
|
||||
}
|
||||
|
||||
fn eval(&self, v: f32) -> f32 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if self.raise {
|
||||
return self.raised(v);
|
||||
}
|
||||
if v <= 0.0 || v >= 1.0 {
|
||||
return v;
|
||||
}
|
||||
// The inverse of the raised curve (monotone on [0, 1]), by bisection.
|
||||
let (mut lo, mut hi) = (0.0f32, 1.0f32);
|
||||
for _ in 0..32 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if self.raised(mid) < v {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
}
|
||||
|
||||
/// The curve raised: the line, then a monotone roll-off to (1, 1).
|
||||
fn raised(&self, x: f32) -> f32 {
|
||||
let Brightness { s, e, x0, alpha, hermite, .. } = *self;
|
||||
let y0 = BRIGHTNESS_KNEE;
|
||||
if x <= x0 {
|
||||
return y0.min(s * x);
|
||||
}
|
||||
let h = (1.0 - x0).max(1e-6);
|
||||
let t = ((x - x0) / h).clamp(0.0, 1.0);
|
||||
let y = if hermite {
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
(2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * h * s + (-2.0 * t3 + 3.0 * t2) + (t3 - t2) * h * e
|
||||
} else {
|
||||
1.0 - (1.0 - y0) * (1.0 - t).powf(alpha)
|
||||
};
|
||||
y.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Modern Brightness/Contrast as one channel's lookup table (4096 samples over 0..1):
|
||||
/// [`modern_brightness`] then [`modern_contrast`], as Photoshop applies them.
|
||||
pub fn modern_lut(brightness: f32, contrast: f32) -> Vec<f32> {
|
||||
let step = |k: usize, n: usize| k as f32 / (n - 1) as f32;
|
||||
let bright: Vec<f32> = if brightness == 0.0 || !brightness.is_finite() {
|
||||
(0..LUT_SIZE).map(|k| step(k, LUT_SIZE)).collect()
|
||||
} else if brightness > 0.0 {
|
||||
let curve = Brightness::new(brightness);
|
||||
(0..LUT_SIZE).map(|k| curve.raised(step(k, LUT_SIZE))).collect()
|
||||
} else {
|
||||
// Lowered: invert the raised curve, sampled four times finer, in one monotone pass.
|
||||
let curve = Brightness::new(-brightness);
|
||||
let fine = 4 * LUT_SIZE;
|
||||
let ys: Vec<f32> = (0..fine).map(|j| curve.raised(step(j, fine))).collect();
|
||||
let mut j = 0;
|
||||
(0..LUT_SIZE)
|
||||
.map(|k| {
|
||||
let y = step(k, LUT_SIZE);
|
||||
while j + 2 < fine && ys.get(j + 1).is_some_and(|&n| n < y) {
|
||||
j += 1;
|
||||
}
|
||||
let (y0, y1) = (ys.get(j).copied().unwrap_or(0.0), ys.get(j + 1).copied().unwrap_or(1.0));
|
||||
let f = if y1 > y0 { ((y - y0) / (y1 - y0)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(step(j, fine) + f / (fine - 1) as f32).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
bright.into_iter().map(|b| modern_contrast(b, contrast).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
/// Modern Contrast curve (one channel, `contrast` in [-50, 100]). A symmetric cubic-Hermite S-curve
|
||||
|
|
@ -854,6 +1127,8 @@ pub fn modern_contrast(v: f32, contrast: f32) -> f32 {
|
|||
if v <= 0.5 { half(v / 0.5) } else { 1.0 - half((1.0 - v) / 0.5) }
|
||||
}
|
||||
|
||||
/// Photoshop posterize: `n` equal input bins over 0..=255, output levels
|
||||
/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels).
|
||||
pub fn posterize(v: f32, levels: u32) -> f32 {
|
||||
let n = levels.clamp(2, 255) as f32;
|
||||
let x = (v.clamp(0.0, 1.0) * 255.0).round();
|
||||
|
|
@ -861,44 +1136,75 @@ pub fn posterize(v: f32, levels: u32) -> f32 {
|
|||
(bin * 255.0 / (n - 1.0)).floor() / 255.0
|
||||
}
|
||||
|
||||
/// [`posterize`] in a document of `depth` (`None`: unknown, treated as 8-bit). At 16 bits
|
||||
/// Photoshop bins the 16-bit sample (`⌊v · n / 32768⌋`) and lands on 8-bit levels through a
|
||||
/// 16-bit step: `⌊b · 32768 / (n - 1)⌋`, converted with `⌊(o · 255 + 16384) / 32768⌋`, so
|
||||
/// 127.5 comes out as 128 but 42.5 as 42 (8 bits floor them all). Exact on every sample of
|
||||
/// psd-tools posterize_16bits_rgb.psd and posterize_16bits_grayscale.psd (levels 2 to 21),
|
||||
/// which the 8-bit rule missed by up to half the range.
|
||||
pub fn posterize_in(v: f32, levels: u32, depth: Option<SampleType>) -> f32 {
|
||||
if depth != Some(SampleType::U16) {
|
||||
return posterize(v, levels);
|
||||
}
|
||||
let n = f64::from(levels.clamp(2, 255));
|
||||
let x = (f64::from(v.clamp(0.0, 1.0)) * 32768.0).round();
|
||||
let bin = (x * n / 32768.0).floor().min(n - 1.0);
|
||||
let step = (bin * 32768.0 / (n - 1.0)).floor();
|
||||
(((step * 255.0 + 16384.0) / 32768.0).floor() / 255.0) as f32
|
||||
}
|
||||
|
||||
pub fn levels(ch: &LevelsChannel, v: f32) -> f32 {
|
||||
levels_q(ch, v, None)
|
||||
}
|
||||
|
||||
/// [`levels`] on samples with `quantum` steps per unit (8-bit: 255). Photoshop works on whole
|
||||
/// levels: the input is a level, and the input-range stretch is rounded (half up) to a whole
|
||||
/// level before the midtone gamma, so a 44..214 range maps 45, 46, 47, 48 → 1.5, 3, 4.5, 6 →
|
||||
/// 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the steps). In a LUT
|
||||
/// the result is a staircase that is exact at every level.
|
||||
/// levels in three steps, each rounded half up to a whole level: the input-range stretch, the
|
||||
/// midtone gamma ([`levels_gamma`]) and the output range. So a 44..214 range maps 45, 46, 47, 48
|
||||
/// → 1.5, 3, 4.5, 6 → 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the
|
||||
/// steps), and an output range narrower than 0..255 maps neighbouring gamma levels to one level
|
||||
/// (psd-tools levels_rgb: 43..48 → 58 through a 0.47 channel gamma and the master's 48..255).
|
||||
/// In a LUT the result is a staircase that is exact at every level.
|
||||
pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
|
||||
let range = (ch.in_white - ch.in_black).max(1e-6);
|
||||
let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) {
|
||||
let quantum = quantum.filter(|q| q.is_finite() && *q >= 1.0);
|
||||
let whole = |x: f32| match quantum {
|
||||
Some(q) => (x * q + 0.5).floor() / q,
|
||||
None => x,
|
||||
};
|
||||
let u = match quantum {
|
||||
Some(q) => {
|
||||
let v = (v * q).round() / q;
|
||||
(((v - ch.in_black) / range).clamp(0.0, 1.0) * q + 0.5 + 1e-3).floor() / q
|
||||
}
|
||||
None => ((v - ch.in_black) / range).clamp(0.0, 1.0),
|
||||
};
|
||||
let g = ch.gamma.max(0.01);
|
||||
let t = if g > 1.0 {
|
||||
// A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black —
|
||||
// which Photoshop bounds, giving a soft shadow toe (initial slope ≈ 2^gamma, verified against
|
||||
// the real app). Soft-min (p-norm) of the power curve with that line; reduces to the exact
|
||||
// power curve in the body, so highlights are unchanged. gamma <= 1 is untouched. Slope and
|
||||
// sharpness fitted on whole-level input (`levels_q`): a 0..255 ramp at gamma 2.0 and
|
||||
// psd-tools levels_grayscale's gamma 1.78 (within one level of Photoshop on both).
|
||||
let power = u.powf(1.0 / g);
|
||||
let line = 0.93 * 2.0f32.powf(g) * u;
|
||||
if power <= 1e-6 || line <= 1e-6 {
|
||||
power.min(line)
|
||||
} else {
|
||||
let p = 10.0; // p-norm sharpness, fit to Photoshop ground truth
|
||||
(power.powf(-p) + line.powf(-p)).powf(-1.0 / p)
|
||||
}
|
||||
} else {
|
||||
u.powf(1.0 / g)
|
||||
};
|
||||
ch.out_black + t * (ch.out_white - ch.out_black)
|
||||
let t = whole(levels_gamma(u, ch.gamma.max(0.01)));
|
||||
whole(ch.out_black + t * (ch.out_white - ch.out_black))
|
||||
}
|
||||
|
||||
/// Exponent of the shadow toe in [`levels_gamma`] (fitted; see there).
|
||||
const LEVELS_TOE_POWER: f32 = 2.2;
|
||||
|
||||
/// Levels' midtone gamma `g` on `u` in 0..1: `u^(1/g)`, except that above 1, where that power
|
||||
/// curve is infinitely steep at black, Photoshop starts along a toe `a·u − b·u^q` of slope
|
||||
/// `a = 2^g` that joins the curve with the same value and slope. `q` = [`LEVELS_TOE_POWER`] is
|
||||
/// fitted on whole-level oracles at gamma 1.16, 1.22, 1.3, 1.78 and 2.0 (psd-tools levels_rgb
|
||||
/// and levels_grayscale, the photoshop corpus's rgb8 and gray8 levels.psd, a measured 0..255
|
||||
/// ramp): all but 2 of their 1118 levels exact, the others within one. Below 1 the power curve is
|
||||
/// exact (gamma 0.67 and 0.7: every level).
|
||||
pub fn levels_gamma(u: f32, g: f32) -> f32 {
|
||||
let c = 1.0 / g;
|
||||
if g <= 1.0 {
|
||||
return u.powf(c);
|
||||
}
|
||||
let (a, q) = (2f32.powf(g), LEVELS_TOE_POWER);
|
||||
// Where the toe meets the curve: a·u1 − b·u1^q = u1^c and a − q·b·u1^(q−1) = c·u1^(c−1).
|
||||
let u1 = ((1.0 - c / q) / (a * (1.0 - 1.0 / q))).powf(1.0 / (1.0 - c));
|
||||
if u >= u1 || u1 <= 0.0 {
|
||||
return u.powf(c);
|
||||
}
|
||||
let b = (a * u1 - u1.powf(c)) / u1.powf(q);
|
||||
a * u - b * u.powf(q)
|
||||
}
|
||||
|
||||
/// [`levels`] in a 32-bit document. Neither the input is clipped to the input range nor the result
|
||||
|
|
@ -1178,6 +1484,41 @@ mod tone_tests {
|
|||
|
||||
/// Photoshop 27.11, measured: Brightness/Contrast with Use Legacy on, over a gray ramp
|
||||
/// (x = 0, 16, …, 240, 255), one row per (brightness, contrast).
|
||||
/// One pixel through `adj` in a 32-bit document.
|
||||
fn hdr(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||||
apply_depth(adj, &mut b, Transfer::Gamma(1.0), Some(SampleType::F32));
|
||||
[b.px[0][0], b.px[0][1], b.px[0][2]]
|
||||
}
|
||||
|
||||
/// Photoshop's own 32-bit results (photoshop corpus rgb32): nothing clips, legacy contrast
|
||||
/// pivots at 127, the mixer cuts its percentages to 1/1024 and counts 255/512 of the
|
||||
/// constant, and the Black & White tint keeps values above 1.
|
||||
#[test]
|
||||
fn thirty_two_bit_adjustments_match_photoshop_unclipped() {
|
||||
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
|
||||
let bc = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true };
|
||||
// brightness-contrast-legacy.psd
|
||||
assert!(near(hdr(&bc, [0.4910, 0.0009, 0.9301]), [0.6280, -0.0721, 1.2553], 2e-4), "{:?}", hdr(&bc, [0.4910, 0.0009, 0.9301]));
|
||||
// channel-mixer.psd: pure red, green, blue and black, exactly.
|
||||
let mixer = Adjustment::ChannelMixer { matrix: [[0.8, 0.3, -0.1, 0.0], [0.1, 0.9, 0.0, 0.05], [0.0, 0.2, 0.7, 0.0]], monochrome: false };
|
||||
for (c, want) in [
|
||||
([1.0, 0.0, 0.0], [0.7998047, 0.1245117, 0.0]),
|
||||
([0.0, 1.0, 0.0], [0.2998047, 0.9243164, 0.1992188]),
|
||||
([0.0, 0.0, 1.0], [-0.0996094, 0.0249023, 0.6992188]),
|
||||
([0.0, 0.0, 0.0], [0.0, 0.0249023, 0.0]),
|
||||
] {
|
||||
assert!(near(hdr(&mixer, c), want, 1e-6), "{c:?}: {:?}", hdr(&mixer, c));
|
||||
}
|
||||
// black-white-tint.psd: white tints past 1.
|
||||
let bw = Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: Some([0.88235295, 0.827451, 0.7019608]) };
|
||||
assert!(near(hdr(&bw, [1.0; 3]), [1.0522, 0.9973, 0.8718], 2e-4), "{:?}", hdr(&bw, [1.0; 3]));
|
||||
// 8/16-bit documents still clip.
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [1.0, 1.0, 1.0, 1.0]);
|
||||
apply_depth(&bw, &mut b, Transfer::Srgb, None);
|
||||
assert!(b.px[0].iter().all(|v| *v <= 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_brightness_contrast_matches_photoshop() {
|
||||
const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255];
|
||||
|
|
@ -1202,6 +1543,48 @@ mod tone_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Pixel → Photoshop's split. 8-bit from psd-tools threshold_rgb.psd: the first three are
|
||||
/// white though their Rec. 601 grey rounds below 60, and (131, 109, 99) sits exactly on 114.5
|
||||
/// at level 115 (halves round down). 16-bit from threshold_16bits_rgb.psd and the photoshop
|
||||
/// corpus's rgb16 threshold-128.psd, whose mid grey 16448 is level 128 in 16 bits.
|
||||
#[test]
|
||||
fn threshold_splits_on_the_luminosity_like_photoshop() {
|
||||
let c8 = |r: u8, g: u8, b: u8| [r, g, b].map(|v| f32::from(v) / 255.0);
|
||||
for (c, level, white) in
|
||||
[(c8(87, 51, 31), 60u8, true), (c8(90, 49, 33), 60, true), (c8(68, 57, 50), 60, true), (c8(48, 63, 72), 60, false), (c8(131, 109, 99), 115, false)]
|
||||
{
|
||||
assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U8)), white, "8-bit {c:?} at {level}");
|
||||
}
|
||||
let c16 = |r: u16, g: u16, b: u16| [r, g, b].map(|v| f32::from(v) / 32768.0);
|
||||
for (c, level, white) in [
|
||||
(c16(8995, 7196, 6425), 60u8, false),
|
||||
(c16(11180, 6554, 3984), 60, false),
|
||||
(c16(16448, 16448, 16448), 128, true),
|
||||
(c16(16447, 16447, 16447), 128, false),
|
||||
] {
|
||||
assert_eq!(threshold_white(c, f32::from(level) / 255.0, Some(SampleType::U16)), white, "16-bit {c:?} at {level}");
|
||||
}
|
||||
// Through the adjustment: neutral black or white.
|
||||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [87.0 / 255.0, 51.0 / 255.0, 31.0 / 255.0, 1.0]);
|
||||
apply_depth(&Adjustment::Threshold { level: 60.0 / 255.0 }, &mut b, Transfer::Srgb, Some(SampleType::U8));
|
||||
assert_eq!(b.px[0], [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// 16-bit sample → Photoshop's 8-bit level, from psd-tools posterize_16bits_rgb.psd: bins
|
||||
/// split on the 16-bit sample (10794 and 10923 straddle a third of 32768), and the middle of 3
|
||||
/// levels is 128 where 8 bits give 127, while 21 levels keep 25 for 25.5.
|
||||
#[test]
|
||||
fn sixteen_bit_posterize_matches_photoshop() {
|
||||
let points: [(u32, u16, u8); 9] =
|
||||
[(3, 10794, 0), (3, 10923, 128), (3, 21845, 128), (3, 21974, 255), (13, 2504, 0), (13, 2542, 21), (13, 7453, 42), (13, 7582, 64), (21, 3341, 25)];
|
||||
for (levels, x, want) in points {
|
||||
let got = posterize_in(f32::from(x) / 32768.0, levels, Some(SampleType::U16));
|
||||
assert!((got * 255.0 - f32::from(want)).abs() < 1e-3, "{levels} levels at {x}: {} vs Photoshop {want}", got * 255.0);
|
||||
}
|
||||
// 8 bits keep their rule.
|
||||
assert_eq!(posterize_in(128.0 / 255.0, 3, Some(SampleType::U8)), posterize(128.0 / 255.0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_lookup_with_an_overflowing_stored_size_is_the_identity() {
|
||||
// A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past
|
||||
|
|
@ -1254,6 +1637,78 @@ mod tone_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Photoshop, measured (8-bit gray ramp, every channel): whole settings, where the tones fold
|
||||
/// into one Levels per channel (#3423). The issue's midtones red +100 / magenta −100 (the
|
||||
/// shadow toe), its sequoia14 layer with Preserve Luminosity off and on, all nine sliders with
|
||||
/// Preserve Luminosity on, a black point past the white point, and lowered shadows with raised
|
||||
/// highlights (their gammas cancel: a straight line between the two points).
|
||||
#[test]
|
||||
fn color_balance_whole_settings_match_photoshop() {
|
||||
const X: [u8; 12] = [0, 4, 8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
type Case = ([[f32; 3]; 3], bool, [[u8; 12]; 3]);
|
||||
let cases: [Case; 6] = [
|
||||
(
|
||||
[[0.0; 3], [100.0, -100.0, 0.0], [0.0; 3]],
|
||||
false,
|
||||
[[0, 16, 30, 55, 90, 128, 156, 181, 202, 221, 239, 255], [0, 0, 0, 1, 4, 16, 36, 64, 100, 145, 197, 255], X],
|
||||
),
|
||||
(
|
||||
[[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]],
|
||||
false,
|
||||
[
|
||||
[0, 0, 2, 9, 25, 56, 89, 122, 156, 188, 222, 255],
|
||||
[0, 5, 9, 18, 35, 67, 100, 131, 163, 194, 225, 255],
|
||||
[0, 3, 7, 14, 29, 60, 91, 123, 156, 189, 222, 255],
|
||||
],
|
||||
),
|
||||
(
|
||||
[[-6.0, 22.0, 5.0], [-1.0, -3.0, -8.0], [-1.0, -4.0, -6.0]],
|
||||
true,
|
||||
[
|
||||
[0, 0, 0, 0, 5, 42, 80, 117, 154, 189, 226, 255],
|
||||
[0, 4, 8, 16, 33, 66, 98, 130, 162, 195, 226, 255],
|
||||
[0, 0, 0, 0, 15, 48, 82, 116, 151, 186, 221, 255],
|
||||
],
|
||||
),
|
||||
(
|
||||
[[40.0, -30.0, 20.0], [-50.0, 25.0, 60.0], [30.0, -70.0, 10.0]],
|
||||
true,
|
||||
[
|
||||
[0, 1, 3, 9, 25, 69, 126, 193, 255, 255, 255, 255],
|
||||
[0, 0, 0, 0, 0, 0, 46, 93, 136, 177, 217, 255],
|
||||
[0, 0, 0, 0, 44, 107, 156, 199, 238, 255, 255, 255],
|
||||
],
|
||||
),
|
||||
(
|
||||
[[100.0, -100.0, 0.0], [0.0; 3], [-100.0, 100.0, 0.0]],
|
||||
true,
|
||||
[X, [0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 0, 0, 0, 130, 255, 255, 255, 255]],
|
||||
),
|
||||
(
|
||||
[[-76.0, -2.0, 60.0], [0.0; 3], [99.0, 0.0, -30.0]],
|
||||
false,
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 71, 171, 255, 255, 255, 255],
|
||||
[0, 2, 6, 14, 29, 61, 94, 126, 158, 191, 224, 255],
|
||||
[0, 6, 11, 21, 39, 73, 106, 137, 168, 197, 227, 255],
|
||||
],
|
||||
),
|
||||
];
|
||||
for (tones, preserve, want) in cases {
|
||||
for (ch, row) in want.iter().enumerate() {
|
||||
for (x, w) in X.iter().zip(row) {
|
||||
let got = (color_balance_channel(tones, preserve, ch, f32::from(*x) / 255.0) * 255.0).round() as i32;
|
||||
assert!((got - i32::from(*w)).abs() <= 1, "{tones:?} pl {preserve} ch {ch} at {x}: {got} vs Photoshop {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hostile input stays in range.
|
||||
for v in [f32::NAN, -1.0, 2.0, f32::INFINITY] {
|
||||
let out = color_balance_channel([[f32::NAN, 1e9, -1e9]; 3], true, 7, v);
|
||||
assert!((0.0..=1.0).contains(&out), "{v}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_balance_midtones_with_multiple_sliders_preserve_luminosity() {
|
||||
// A shared offset depends on the two extremes, not the sum of moved sliders.
|
||||
|
|
@ -1525,6 +1980,18 @@ mod tone_tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_map_follows_the_w3c_luminosity() {
|
||||
// Photoshop's Gradient Map is driven by 0.3 R + 0.59 G + 0.11 B (photoshop corpus rgb8 and
|
||||
// rgb16 gradient-map.psd), not the Rec. 601 grey.
|
||||
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
|
||||
for (c, t) in [([0.0, 1.0, 0.0], 0.59), ([1.0, 0.0, 0.0], 0.3), ([0.0, 0.0, 1.0], 0.11), ([0.2, 0.6, 0.9], 0.513)] {
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||||
apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut b);
|
||||
assert!((b.px[0][0] - t).abs() < 1e-5, "{c:?}: {}", b.px[0][0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_map_dither_stays_close() {
|
||||
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
|
||||
|
|
@ -1600,6 +2067,64 @@ mod tone_tests {
|
|||
assert!(near(in_cmyk(&t, [0.8; 3]), cmyk_to_rgb([0.0; 4]), 2e-3));
|
||||
}
|
||||
|
||||
/// CMYK posterizes the stored value 1 - ink: with 3 levels an ink of 85 (stored 170) lands on
|
||||
/// the middle step, 128 (stored 127), where posterizing the ink would drop it to none (psd-tools
|
||||
/// posterize_cmyk.psd, levels 3 to 21, exact so).
|
||||
#[test]
|
||||
fn cmyk_posterize_works_on_the_stored_values() {
|
||||
let mut ink = [85.0 / 255.0; 4];
|
||||
stored_channels(&Adjustment::Posterize { levels: 3 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ink);
|
||||
assert!(ink.iter().all(|v| (v * 255.0 - 128.0).abs() < 1e-3), "{ink:?}");
|
||||
assert_eq!(posterize(85.0 / 255.0, 3), 0.0, "posterizing the ink itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_threshold_compares_the_inks_plain_luma() {
|
||||
assert_eq!(ink_luma([0.0; 4]), 1.0);
|
||||
assert_eq!(ink_luma([0.0, 0.0, 0.0, 1.0]), 0.0);
|
||||
// Two greys either side of level 128 in photoshop corpus cmyk8 threshold-128.psd: black,
|
||||
// then white (8-bit inks).
|
||||
let at = |i: [f32; 4]| {
|
||||
let mut ch = i.map(|v| v / 255.0);
|
||||
stored_channels(&Adjustment::Threshold { level: 128.0 / 255.0 }, ColorMode::Cmyk, Some(SampleType::U8), &mut ch);
|
||||
ch
|
||||
};
|
||||
assert_eq!(at([144.0, 122.0, 121.0, 35.0]), [0.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(at([131.0, 110.0, 110.0, 19.0]), [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_brightness_contrast_curves_each_ink_from_paper() {
|
||||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||||
// The tone curve runs on each ink's stored value, 1 - ink (photoshop corpus cmyk8
|
||||
// brightness-contrast.psd and -legacy.psd): raising brightness takes ink away, and paper
|
||||
// and solid ink stay put.
|
||||
let red = [0.9, 0.2, 0.1];
|
||||
for legacy in [false, true] {
|
||||
let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy };
|
||||
let table = ink_brightness_contrast_lut(25.0, 30.0, legacy);
|
||||
let want = cmyk_to_rgb(rgb_to_cmyk(red).map(|v| 1.0 - lut(&table, 1.0 - v)));
|
||||
assert!(near(in_cmyk(&adj, red), want, 2e-3), "legacy {legacy}");
|
||||
assert!(near(in_cmyk(&adj, [1.0; 3]), [1.0; 3], 2e-3), "legacy {legacy}: paper");
|
||||
let solid = cmyk_to_rgb([1.0; 4]);
|
||||
assert!(near(in_cmyk(&adj, solid), cmyk_to_rgb(rgb_to_cmyk(solid).map(|v| ink_through(&table, v))), 2e-3));
|
||||
}
|
||||
// Legacy +25 / +30 on Photoshop's own 16-bit inks (photoshop corpus cmyk16
|
||||
// brightness-contrast-legacy.psd): contrast pivots at 128 in ink, 127 stored.
|
||||
let legacy = ink_brightness_contrast_lut(25.0, 30.0, true);
|
||||
for (ink, ps) in [(139.0, 108.0), (160.0, 137.99), (172.0, 155.13), (183.0, 170.85), (196.0, 189.42), (98.0, 49.42), (128.0, 92.28), (168.0, 149.42)] {
|
||||
let got = ink_through(&legacy, ink / 255.0) * 255.0;
|
||||
assert!((got - ps).abs() < 0.1, "ink {ink}: {got} vs Photoshop {ps}");
|
||||
}
|
||||
let brighter = Adjustment::BrightnessContrast { brightness: 60.0, contrast: 0.0, legacy: false };
|
||||
let ink = ink_brightness_contrast_lut(60.0, 0.0, false);
|
||||
assert!(ink_through(&ink, 0.5) < 0.5 && ink_through(&ink, 0.0) == 0.0 && (ink_through(&ink, 1.0) - 1.0).abs() < 1e-6);
|
||||
// On the screen colour it would land elsewhere.
|
||||
let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]);
|
||||
apply(&brighter, &mut rgb);
|
||||
assert!(!near(in_cmyk(&brighter, red), [rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]], 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_posterize_steps_l_a_and_b() {
|
||||
let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0));
|
||||
|
|
|
|||
191
crates/compose/src/filter_mask.rs
Normal file
191
crates/compose/src/filter_mask.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
//! A smart object's filter mask (#215): how the filtered pixels and the unfiltered (placed) ones
|
||||
//! combine.
|
||||
//!
|
||||
//! Photoshop stores a smart object's layer pixels with its filters applied but not its filter
|
||||
//! mask, and mixes the unfiltered pixels back in through the mask when it composites (photoshop
|
||||
//! corpus `sf-filter-mask-hard.psd` and `sf-filter-mask-gradient.psd`: the stored pixels are
|
||||
//! filtered everywhere, the merged image only where the mask shows the filter). Our smart objects
|
||||
//! keep the mixed result as their pixels, so PSD import mixes ([`mix`]) and export unmixes
|
||||
//! ([`unmix`]).
|
||||
|
||||
use photocraft_doc::LayerMask;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// `filtered` mixed over `base` (the unfiltered pixels) through `mask`: premultiplied, any
|
||||
/// channel layout. Pixels the mask hides entirely are `base`'s.
|
||||
pub fn mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface {
|
||||
let fmt = base.format();
|
||||
let area = base.content_bounds().union(&filtered.content_bounds());
|
||||
let mut out = filtered.clone();
|
||||
if area.is_empty() || filtered.format() != fmt {
|
||||
return out;
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let (b, f) = (base.read_region(area), filtered.read_region(area));
|
||||
let mut k = Vec::new();
|
||||
mask.values_into(area, &mut k);
|
||||
let mut o = vec![0.0f32; b.len()];
|
||||
for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
|
||||
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
if !fmt.alpha {
|
||||
for c in 0..n {
|
||||
op[c] = bp[c] + (fp[c] - bp[c]) * m;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let a = n - 1;
|
||||
let oa = bp[a] + (fp[a] - bp[a]) * m;
|
||||
op[a] = oa;
|
||||
for c in 0..a {
|
||||
let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m;
|
||||
op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
out.write_region(area, &o);
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
|
||||
/// The mask value below which a pixel's filtered value is taken to be unknown: [`unmix`] gives the
|
||||
/// unfiltered value there, which the mask shows (almost) none of anyway.
|
||||
const HIDDEN: f32 = 1.0 / 1024.0;
|
||||
|
||||
/// The inverse of [`mix`]: the filtered pixels that mix with `base` through `mask` to `mixed`
|
||||
/// (clamped to 0..1). Where the mask hides the filter entirely they are `base`'s, which Photoshop
|
||||
/// doesn't show there.
|
||||
pub fn unmix(base: &Surface, mixed: &Surface, mask: &LayerMask) -> Surface {
|
||||
let fmt = base.format();
|
||||
let area = base.content_bounds().union(&mixed.content_bounds());
|
||||
let mut out = mixed.clone();
|
||||
if area.is_empty() || mixed.format() != fmt {
|
||||
return out;
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let (b, x) = (base.read_region(area), mixed.read_region(area));
|
||||
let mut k = Vec::new();
|
||||
mask.values_into(area, &mut k);
|
||||
let mut o = vec![0.0f32; b.len()];
|
||||
for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
|
||||
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
if m < HIDDEN {
|
||||
// A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the
|
||||
// stored pixels don't grow past the content.
|
||||
if !fmt.alpha || bp[n - 1] > 0.0 {
|
||||
op.copy_from_slice(bp);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !fmt.alpha {
|
||||
for c in 0..n {
|
||||
op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let a = n - 1;
|
||||
let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0);
|
||||
if fa <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
op[a] = fa;
|
||||
for c in 0..a {
|
||||
let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m;
|
||||
op[c] = (pm / fa).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
out.write_region(area, &o);
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::LayerMask;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn surface(fmt: PixelFormat, f: impl Fn(i32, i32) -> Vec<f32>) -> Surface {
|
||||
let mut s = Surface::new(fmt);
|
||||
for y in 0..4 {
|
||||
for x in 0..8 {
|
||||
s.write_pixel(x, y, &f(x, y));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A mask ramping from hidden (left) to shown (right).
|
||||
fn ramp() -> LayerMask {
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface = surface(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), |x, _| vec![x as f32 / 7.0]);
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmix_inverts_mix_wherever_the_mask_shows_the_filter() {
|
||||
for fmt in [PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), PixelFormat::new(ColorMode::Rgb, SampleType::F32, false)] {
|
||||
let base = surface(fmt, |x, y| {
|
||||
let mut p = vec![0.2 + 0.05 * x as f32, 0.6, 0.1 * y as f32];
|
||||
if fmt.alpha {
|
||||
p.push(0.5 + 0.06 * x as f32);
|
||||
}
|
||||
p
|
||||
});
|
||||
let filtered = surface(fmt, |_, y| {
|
||||
let mut p = vec![0.9 - 0.1 * y as f32, 0.3, 0.5];
|
||||
if fmt.alpha {
|
||||
p.push(0.8);
|
||||
}
|
||||
p
|
||||
});
|
||||
let m = ramp();
|
||||
let mixed = mix(&base, &filtered, &m);
|
||||
// Hidden column: the unfiltered pixels; shown column: the filtered ones.
|
||||
assert!(mixed.pixel(0, 2).iter().zip(base.pixel(0, 2)).all(|(a, b)| (a - b).abs() < 1e-6));
|
||||
assert!(mixed.pixel(7, 2).iter().zip(filtered.pixel(7, 2)).all(|(a, b)| (a - b).abs() < 1e-6));
|
||||
let back = unmix(&base, &mixed, &m);
|
||||
for y in 0..4 {
|
||||
for x in 1..8 {
|
||||
let (g, w) = (back.pixel(x, y), filtered.pixel(x, y));
|
||||
assert!(g.iter().zip(&w).all(|(a, b)| (a - b).abs() < 1e-4), "{fmt:?} ({x}, {y}): {g:?} vs {w:?}");
|
||||
}
|
||||
// Where the mask hides the filter entirely its value is unknown: the base stands in.
|
||||
assert_eq!(back.pixel(0, y), base.pixel(0, y));
|
||||
}
|
||||
// So mixing the unmixed pixels gives the mixed ones back everywhere.
|
||||
let again = mix(&base, &back, &m);
|
||||
for y in 0..4 {
|
||||
for x in 0..8 {
|
||||
assert!(again.pixel(x, y).iter().zip(mixed.pixel(x, y)).all(|(a, b)| (a - b).abs() < 1e-4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_base_pixels_stay_empty() {
|
||||
// Photoshop's unfiltered cache carries white in transparent pixels; where the mask hides
|
||||
// the filter those must not become content of the stored pixels.
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
|
||||
let base = surface(fmt, |x, _| if x < 4 { vec![1.0, 1.0, 1.0, 0.0] } else { vec![0.3, 0.4, 0.5, 1.0] });
|
||||
let mut hidden = LayerMask::reveal_all();
|
||||
hidden.surface = Surface::with_default(PixelFormat::GRAY8, &[0.0]);
|
||||
let mixed = mix(&base, &base, &hidden);
|
||||
let back = unmix(&base, &mixed, &hidden);
|
||||
assert_eq!(back.pixel(1, 1), vec![0.0; 4]);
|
||||
assert_eq!(back.content_bounds().x0, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_or_empty_surfaces_are_left_alone() {
|
||||
let rgba = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
|
||||
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, true);
|
||||
let a = surface(rgba, |_, _| vec![0.5, 0.5, 0.5, 1.0]);
|
||||
let b = surface(gray, |_, _| vec![0.1, 1.0]);
|
||||
assert_eq!(mix(&a, &b, &ramp()), b);
|
||||
assert_eq!(unmix(&a, &b, &ramp()), b);
|
||||
let empty = Surface::new(rgba);
|
||||
assert_eq!(unmix(&empty, &empty, &ramp()), empty);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,10 +21,12 @@ pub mod advanced;
|
|||
pub mod bounds;
|
||||
pub mod effects;
|
||||
pub mod fill_layout;
|
||||
pub mod filter_mask;
|
||||
pub mod gradient_fill;
|
||||
pub mod masks;
|
||||
pub mod multichannel;
|
||||
pub mod native;
|
||||
pub mod native_lab;
|
||||
pub mod pattern;
|
||||
pub mod proxy;
|
||||
pub mod psblend;
|
||||
|
|
@ -128,6 +130,14 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
|||
if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS {
|
||||
return Buffer::transparent(Rect::EMPTY);
|
||||
}
|
||||
// A CMYK stack with adjustment layers the inks carry exactly composites on its inks.
|
||||
if let Some(buf) = native::render_cmyk(doc, rect, tile) {
|
||||
return buf;
|
||||
}
|
||||
// A Lab stack with adjustment layers that work on L, a and b composites on its stored values.
|
||||
if let Some(buf) = native_lab::render_lab(doc, rect, tile) {
|
||||
return buf;
|
||||
}
|
||||
cx.set_fx_clip(rect);
|
||||
if i64::from(rect.width()) <= i64::from(tile) && i64::from(rect.height()) <= i64::from(tile) {
|
||||
return photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
|
|
@ -260,7 +270,8 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
|
|||
/// otherwise count as content: the result would span the whole canvas.
|
||||
///
|
||||
/// A CMYK document of plain Normal layers is composited in its ink values instead
|
||||
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778).
|
||||
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778),
|
||||
/// and a Lab one in its L, a and b ([`native_lab::flatten_lab`]).
|
||||
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
|
||||
composite_to_surface(doc, doc.bounds(), fmt, background)
|
||||
}
|
||||
|
|
@ -273,6 +284,11 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
|
|||
{
|
||||
return s;
|
||||
}
|
||||
if (background.is_none() || paper)
|
||||
&& let Some(s) = native_lab::flatten_lab(doc, rect, fmt, paper)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
let mut s = Surface::new(fmt);
|
||||
let n = fmt.channels();
|
||||
let empty = s.default_pixel();
|
||||
|
|
@ -326,6 +342,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
|||
fx_maps: Default::default(),
|
||||
fx_clip: Default::default(),
|
||||
doc: None,
|
||||
layer_keys: Default::default(),
|
||||
},
|
||||
advanced::Scope::ROOT,
|
||||
);
|
||||
|
|
@ -523,6 +540,60 @@ struct Ctx<'a> {
|
|||
fx_clip: std::sync::Mutex<Option<Rect>>,
|
||||
/// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer.
|
||||
doc: Option<&'a Document>,
|
||||
/// Effect-map identities of this render's document layers (see [`LayerKeys`]).
|
||||
layer_keys: LayerKeys,
|
||||
}
|
||||
|
||||
/// Memo of [`layer_identity`] for the layers of the document being rendered. The identity depends
|
||||
/// on the layer alone, not on the tile, so it is computed once per layer per render instead of
|
||||
/// once per tile. Only layers that sit in the document tree are memoised by address: the document
|
||||
/// is borrowed for the whole render, so no other object can live at such an address. Temporary
|
||||
/// copies (`advanced::effects_source`) are never in the tree and always take the full path.
|
||||
#[derive(Default)]
|
||||
struct LayerKeys {
|
||||
/// Addresses of every layer in the document tree, collected on first use.
|
||||
in_doc: std::sync::OnceLock<std::collections::HashSet<usize>>,
|
||||
ids: std::sync::Mutex<std::collections::HashMap<usize, u64>>,
|
||||
/// Identities computed (tests count them).
|
||||
#[cfg(test)]
|
||||
built: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl LayerKeys {
|
||||
fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 {
|
||||
let addr = std::ptr::from_ref(layer) as usize;
|
||||
let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr));
|
||||
if in_doc {
|
||||
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Computing an identity only hashes immutable layer data; it does not run Rayon
|
||||
// work or re-enter this memo. Keep the miss and insertion atomic across callers.
|
||||
*ids.entry(addr).or_insert_with(|| self.compute(layer))
|
||||
} else {
|
||||
self.compute(layer)
|
||||
}
|
||||
}
|
||||
|
||||
fn compute(&self, layer: &Layer) -> u64 {
|
||||
use std::hash::Hasher;
|
||||
#[cfg(test)]
|
||||
self.built.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
layer_identity(layer, &mut h, &mut |_| {});
|
||||
h.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Addresses of all layers of `doc` (groups' children included).
|
||||
fn doc_layer_addresses(doc: &Document) -> std::collections::HashSet<usize> {
|
||||
let mut out = std::collections::HashSet::new();
|
||||
let mut todo: Vec<&Layer> = doc.layers.iter().collect();
|
||||
while let Some(l) = todo.pop() {
|
||||
out.insert(std::ptr::from_ref(l) as usize);
|
||||
if let LayerContent::Group(g) = &l.content {
|
||||
todo.extend(g.children.iter());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
|
|
@ -538,6 +609,7 @@ impl<'a> Ctx<'a> {
|
|||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
fx_clip: Default::default(),
|
||||
layer_keys: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1290,11 +1362,17 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
|
||||
// Pass-through groups composite their children straight into the backdrop. Below 100% fill
|
||||
// Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an
|
||||
// adjustment inside no longer reaches the layers beneath).
|
||||
// adjustment inside no longer reaches the layers beneath), and so it does for a group that
|
||||
// is the base of a clipping group and holds an adjustment layer (psd-tools
|
||||
// passthrough_clipping_mask_adjustment: an Exposure inside leaves the layers beneath alone),
|
||||
// while such a group's blend modes still reach the backdrop (passthrough_clipping_mask_blendmode).
|
||||
let clip_base_adjusts = clipped.iter().any(|c| c.visible)
|
||||
&& matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_))));
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !effects::has_effects(layer)
|
||||
&& !clip_base_adjusts
|
||||
{
|
||||
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
|
||||
let has_clipped = clipped.iter().any(|c| c.visible);
|
||||
|
|
@ -1454,11 +1532,12 @@ fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, O
|
|||
|
||||
/// Order-independent identity of a layer's pixels, masks, effects, Advanced Blending and Blend If
|
||||
/// and (for groups) children.
|
||||
/// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the
|
||||
/// layer so those tiles (and their addresses) stay alive while the entry exists.
|
||||
fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) {
|
||||
/// Pixels are identified by their copy-on-write tile pointers. `pin` sees every tile hashed: a
|
||||
/// cache entry keeps a [`std::sync::Weak`] to each, which reserves the tile's address (so no
|
||||
/// other tile can reuse it and match the key) without keeping its pixels alive (#4530).
|
||||
fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher, pin: &mut dyn FnMut(&std::sync::Arc<photocraft_raster::Tile>)) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
fn surface_fp(s: &Surface) -> u64 {
|
||||
fn surface_fp(s: &Surface, pin: &mut dyn FnMut(&std::sync::Arc<photocraft_raster::Tile>)) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
s.format().hash(&mut h);
|
||||
// Missing tiles read the default pixel, so tile identity alone is not pixel identity.
|
||||
|
|
@ -1466,6 +1545,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
|
|||
sample.to_bits().hash(&mut h);
|
||||
}
|
||||
let tiles = s.tiles().fold(s.tile_count() as u64, |acc, (c, t)| {
|
||||
pin(t);
|
||||
let mut x = (std::sync::Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 40) ^ ((c.ty as u32 as u64) << 8);
|
||||
x = x.wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
acc.wrapping_add(x ^ (x >> 29))
|
||||
|
|
@ -1486,7 +1566,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
|
|||
// composites (unlike the group's own visibility and opacity) shapes them.
|
||||
(c.visible, c.opacity.to_bits(), c.clipped).hash(h);
|
||||
format!("{:?}", c.blend).hash(h);
|
||||
layer_identity(c, h);
|
||||
layer_identity(c, h, pin);
|
||||
}
|
||||
format!("{:?}", g.artboard).hash(h);
|
||||
}
|
||||
|
|
@ -1498,14 +1578,14 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
|
|||
if let Some(c) = &layer.fill_cache
|
||||
&& c.fill == *f
|
||||
{
|
||||
surface_fp(&c.surface).hash(h);
|
||||
surface_fp(&c.surface, pin).hash(h);
|
||||
}
|
||||
}
|
||||
LayerContent::Adjustment(a) => format!("{a:?}").hash(h),
|
||||
_ => layer.surface().map_or(0, surface_fp).hash(h),
|
||||
_ => layer.surface().map_or(0, |s| surface_fp(s, pin)).hash(h),
|
||||
}
|
||||
if let Some(m) = &layer.mask {
|
||||
(surface_fp(&m.surface), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h);
|
||||
(surface_fp(&m.surface, pin), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h);
|
||||
}
|
||||
if let Some(vm) = &layer.vector_mask {
|
||||
format!("{vm:?}").hash(h);
|
||||
|
|
@ -1520,7 +1600,8 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
|
|||
|
||||
struct FxEntry {
|
||||
maps: std::sync::Arc<effects::FxMaps>,
|
||||
_pin: Layer,
|
||||
/// The tiles the key identifies (see [`layer_identity`]): their addresses, not their pixels.
|
||||
_pin: Vec<std::sync::Weak<photocraft_raster::Tile>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
|
|
@ -1603,7 +1684,7 @@ fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc<effects::F
|
|||
return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns));
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
layer_identity(layer, &mut h);
|
||||
cx.layer_keys.identity(layer, cx.doc).hash(&mut h);
|
||||
(region.x0, region.y0, region.x1, region.y1).hash(&mut h);
|
||||
(cx.light.angle.to_bits(), cx.light.altitude.to_bits()).hash(&mut h);
|
||||
let key = h.finish();
|
||||
|
|
@ -1639,9 +1720,13 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s
|
|||
None => {
|
||||
let shape: Vec<f32> = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
|
||||
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
|
||||
let bytes = maps.bytes();
|
||||
let mut pin = Vec::new();
|
||||
layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| pin.push(std::sync::Arc::downgrade(t)));
|
||||
// The maps, plus what each reserved tile address keeps allocated.
|
||||
let bytes =
|
||||
maps.bytes().saturating_add(pin.len().saturating_mul(std::mem::size_of::<photocraft_raster::Tile>() + 2 * std::mem::size_of::<usize>()));
|
||||
let maps = std::sync::Arc::new(maps);
|
||||
if slot.set(FxEntry { maps: maps.clone(), _pin: layer.clone(), bytes }).is_ok() {
|
||||
if slot.set(FxEntry { maps: maps.clone(), _pin: pin, bytes }).is_ok() {
|
||||
// Counted exactly once, by the build that filled the slot, and only while the
|
||||
// slot is still cached (eviction may have dropped it meanwhile).
|
||||
let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
|
|
|||
|
|
@ -171,9 +171,10 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
|||
h.finish()
|
||||
}
|
||||
|
||||
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the
|
||||
/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather
|
||||
/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it).
|
||||
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself.
|
||||
/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector
|
||||
/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's
|
||||
/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges).
|
||||
pub fn feather_sigma(px: f32) -> f32 {
|
||||
if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 }
|
||||
}
|
||||
|
|
@ -188,37 +189,73 @@ pub fn has_feather(layer: &Layer) -> bool {
|
|||
|| layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0)
|
||||
}
|
||||
|
||||
/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just
|
||||
/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct BoxPass {
|
||||
r: usize,
|
||||
end: f32,
|
||||
}
|
||||
|
||||
impl BoxPass {
|
||||
/// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et
|
||||
/// al. 2011), or `None` when there is nothing to blur.
|
||||
fn for_sigma(sigma: f32) -> Option<BoxPass> {
|
||||
if !sigma.is_finite() || sigma <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow.
|
||||
let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0;
|
||||
// The largest whole radius whose box variance r(r+1)/3 is at most v.
|
||||
let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize;
|
||||
let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0;
|
||||
while r > 0 && var(r) > v {
|
||||
r -= 1;
|
||||
}
|
||||
while var(r + 1) <= v {
|
||||
r += 1;
|
||||
}
|
||||
let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0);
|
||||
let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0));
|
||||
Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one of the three box passes to a single line. `scratch` preserves the input while
|
||||
/// the sliding sum writes the output, matching the previous sequential implementation exactly.
|
||||
fn blur_line(line: &mut [f32], scratch: &mut [f32], r: usize) {
|
||||
fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) {
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(radius) = isize::try_from(r) else { return };
|
||||
let Ok(radius) = isize::try_from(pass.r) else { return };
|
||||
scratch.copy_from_slice(line);
|
||||
let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize];
|
||||
let mut acc: f32 = (-radius..=radius).map(get).sum();
|
||||
let n = (2 * r + 1) as f32;
|
||||
let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end;
|
||||
// The sample leaving the window is the next one's low end weight.
|
||||
let mut below = get(-radius - 1);
|
||||
for (i, dst) in line.iter_mut().enumerate() {
|
||||
*dst = acc / n;
|
||||
let i = i as isize;
|
||||
acc += get(i + radius + 1) - get(i - radius);
|
||||
let (above, leaving) = (get(i + radius + 1), get(i - radius));
|
||||
*dst = (acc + pass.end * (below + above)) / n;
|
||||
acc += above - leaving;
|
||||
below = leaving;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used
|
||||
/// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon
|
||||
/// work here can deadlock when every worker waits on the same OnceLock (#276).
|
||||
fn blur_rows_chunk(v: &mut [f32], w: usize, r: usize) {
|
||||
fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) {
|
||||
let mut scratch = vec![0.0; w];
|
||||
for row in v.chunks_mut(w) {
|
||||
for _ in 0..3 {
|
||||
blur_line(row, &mut scratch, r);
|
||||
blur_line(row, &mut scratch, pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blur_rows(v: &mut [f32], w: usize, r: usize) {
|
||||
fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) {
|
||||
if w == 0 || v.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -229,14 +266,14 @@ fn blur_rows(v: &mut [f32], w: usize, r: usize) {
|
|||
std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1))
|
||||
};
|
||||
if threads == 1 {
|
||||
blur_rows_chunk(v, w, r);
|
||||
blur_rows_chunk(v, w, pass);
|
||||
return;
|
||||
}
|
||||
let rows_per_worker = h.div_ceil(threads);
|
||||
let chunk_len = rows_per_worker.saturating_mul(w);
|
||||
std::thread::scope(|scope| {
|
||||
for chunk in v.chunks_mut(chunk_len) {
|
||||
scope.spawn(move || blur_rows_chunk(chunk, w, r));
|
||||
scope.spawn(move || blur_rows_chunk(chunk, w, pass));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -256,35 +293,34 @@ fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which
|
||||
/// is exact here since the planes extend into constant mask regions). The axes are processed in
|
||||
/// parallel on bounded OS threads, preserving each line's floating-point operation order.
|
||||
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is
|
||||
/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into
|
||||
/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like
|
||||
/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels
|
||||
/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes
|
||||
/// are processed in parallel on bounded OS threads, preserving each line's floating-point
|
||||
/// operation order.
|
||||
fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) {
|
||||
if sigma <= 0.0 || w == 0 || h == 0 || v.len() != w.saturating_mul(h) {
|
||||
return;
|
||||
}
|
||||
// Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi).
|
||||
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
|
||||
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
|
||||
if r == 0 {
|
||||
if w == 0 || h == 0 || v.len() != w.saturating_mul(h) {
|
||||
return;
|
||||
}
|
||||
let Some(pass) = BoxPass::for_sigma(sigma) else { return };
|
||||
// Reserve before modifying the input so allocation failure can use the sequential fallback
|
||||
// without leaving a half-blurred mask.
|
||||
let mut transposed = Vec::new();
|
||||
if transposed.try_reserve_exact(v.len()).is_err() {
|
||||
gaussian_sequential(v, w, h, r);
|
||||
gaussian_sequential(v, w, h, pass);
|
||||
return;
|
||||
}
|
||||
transposed.resize(v.len(), 0.0);
|
||||
blur_rows(v, w, r);
|
||||
blur_rows(v, w, pass);
|
||||
transpose_into(v, &mut transposed, w, h);
|
||||
blur_rows(&mut transposed, h, r);
|
||||
blur_rows(&mut transposed, h, pass);
|
||||
transpose_into(&transposed, v, h, w);
|
||||
}
|
||||
|
||||
/// Allocation-light reference path used if parallel transpose storage cannot be reserved.
|
||||
fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) {
|
||||
fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) {
|
||||
let mut line = Vec::new();
|
||||
let mut scratch = Vec::new();
|
||||
let len = w.max(h);
|
||||
|
|
@ -300,7 +336,7 @@ fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) {
|
|||
line[i] = if axis { v[i * w + k] } else { v[k * w + i] };
|
||||
}
|
||||
for _ in 0..3 {
|
||||
blur_line(&mut line[..line_len], &mut scratch[..line_len], r);
|
||||
blur_line(&mut line[..line_len], &mut scratch[..line_len], pass);
|
||||
}
|
||||
for i in 0..line_len {
|
||||
if axis {
|
||||
|
|
@ -693,14 +729,55 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole
|
||||
/// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46).
|
||||
#[test]
|
||||
fn feather_blur_has_the_exact_variance() {
|
||||
for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] {
|
||||
let (w, c) = (801usize, 400usize);
|
||||
let mut v = vec![0.0f32; w];
|
||||
v[c] = 1.0;
|
||||
gaussian(&mut v, w, 1, sigma);
|
||||
// Within the kernel's reach (the running sums leave ~1e-8 behind further out).
|
||||
let reach = (8.0 * sigma) as usize + 4;
|
||||
let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum();
|
||||
assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt());
|
||||
assert!((v.iter().sum::<f32>() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total");
|
||||
}
|
||||
assert_eq!(BoxPass::for_sigma(0.0), None);
|
||||
assert_eq!(BoxPass::for_sigma(f32::NAN), None);
|
||||
assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end)));
|
||||
}
|
||||
|
||||
/// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools
|
||||
/// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it
|
||||
/// within 1.5 levels, where whole-pixel boxes were 4 off.
|
||||
#[test]
|
||||
fn a_feathered_edge_is_a_gaussian_step() {
|
||||
// erf, Abramowitz & Stegun 7.1.26 (1.5e-7).
|
||||
let erf = |x: f64| {
|
||||
let t = 1.0 / (1.0 + 0.327_591_1 * x.abs());
|
||||
let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp();
|
||||
y.copysign(x)
|
||||
};
|
||||
let (w, edge, sigma) = (240usize, 120usize, 6.0f32);
|
||||
let mut v: Vec<f32> = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect();
|
||||
gaussian(&mut v, w, 1, sigma);
|
||||
for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) {
|
||||
let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma);
|
||||
let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2));
|
||||
// Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6).
|
||||
assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_gaussian_matches_the_sequential_reference() {
|
||||
let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0);
|
||||
let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect();
|
||||
let mut sequential = parallel.clone();
|
||||
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
|
||||
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
|
||||
gaussian_sequential(&mut sequential, w, h, r);
|
||||
let pass = BoxPass::for_sigma(sigma).unwrap();
|
||||
gaussian_sequential(&mut sequential, w, h, pass);
|
||||
gaussian(&mut parallel, w, h, sigma);
|
||||
assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic");
|
||||
}
|
||||
|
|
@ -711,12 +788,11 @@ mod tests {
|
|||
use std::time::Instant;
|
||||
|
||||
let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0);
|
||||
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
|
||||
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
|
||||
let pass = BoxPass::for_sigma(sigma).unwrap();
|
||||
let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect();
|
||||
let mut sequential = source.clone();
|
||||
let start = Instant::now();
|
||||
gaussian_sequential(&mut sequential, w, h, r);
|
||||
gaussian_sequential(&mut sequential, w, h, pass);
|
||||
let sequential_time = start.elapsed();
|
||||
let mut parallel = source;
|
||||
let start = Instant::now();
|
||||
|
|
|
|||
|
|
@ -1,85 +1,255 @@
|
|||
//! Mode-native flattening of plain CMYK layer stacks (#3778).
|
||||
//! Mode-native compositing of plain CMYK layer stacks (#3778).
|
||||
//!
|
||||
//! The compositor works in display RGB, so flattening a CMYK document through it reads every ink
|
||||
//! value through the profile and separates it again: even a lone Background comes back with
|
||||
//! different ink. When every visible layer is a plain raster layer in Normal mode (no masks,
|
||||
//! effects, clipping or advanced blending), the composite needs no colour conversion at all:
|
||||
//! Normal mixes ink values linearly, as Photoshop does in CMYK, so it is worked out on the stored
|
||||
//! values and an opaque layer keeps them exactly.
|
||||
//! values and an opaque layer keeps them exactly. A layer mask (unfeathered) scales the mix, as
|
||||
//! it does there.
|
||||
//!
|
||||
//! Plain adjustment layers that Photoshop evaluates on the inks (Invert, Posterize, Threshold,
|
||||
//! Brightness/Contrast, Hue/Saturation, Color Balance, and Levels and Curves on CMYK channels)
|
||||
//! join such a stack too: on the
|
||||
//! stored inks their rules are exact, where the RGB compositor can only apply them to a separation
|
||||
//! of its RGB composite, whose black differs from the file's. Such stacks are also rendered for
|
||||
//! display this way ([`render_cmyk`]); stacks of raster layers alone keep the RGB compositor there.
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_color::{ColorMode, PixelFormat};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::adjust::ToneSpace;
|
||||
use photocraft_doc::{Adjustment, Document, Layer, LayerContent, LayerMask};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::{Buffer, adjust};
|
||||
|
||||
/// Process channels of a CMYK pixel.
|
||||
const INKS: usize = 4;
|
||||
|
||||
/// A layer the native path can composite: a visible raster layer the compositor would only blend
|
||||
/// Normally at its opacity.
|
||||
fn plain(layer: &Layer) -> Option<&Surface> {
|
||||
let LayerContent::Raster(s) = &layer.content else { return None };
|
||||
let simple = layer.blend == BlendMode::Normal
|
||||
/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask:
|
||||
/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced
|
||||
/// blending.
|
||||
fn simple(layer: &Layer) -> bool {
|
||||
layer.blend == BlendMode::Normal
|
||||
&& !layer.clipped
|
||||
&& layer.mask.as_ref().is_none_or(|m| !m.enabled)
|
||||
&& !crate::masks::has_feather(layer)
|
||||
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
|
||||
&& !crate::effects::has_effects(layer)
|
||||
&& layer.excluded_channels == 0
|
||||
&& layer.blend_if.is_default()
|
||||
&& layer.advanced.is_default()
|
||||
&& layer.video.is_none()
|
||||
&& s.format().mode == ColorMode::Cmyk;
|
||||
simple.then_some(s)
|
||||
}
|
||||
|
||||
/// A layer the native path can composite: a visible raster layer the compositor would only blend
|
||||
/// Normally at its opacity.
|
||||
fn plain(layer: &Layer) -> Option<&Surface> {
|
||||
let LayerContent::Raster(s) = &layer.content else { return None };
|
||||
(simple(layer) && s.format().mode == ColorMode::Cmyk).then_some(s)
|
||||
}
|
||||
|
||||
/// An adjustment on straight inks, prepared once per composite.
|
||||
enum InkOp<'a> {
|
||||
/// Each ink's stored value (1 - ink) through its channel's table: C, M, Y, K
|
||||
/// (`adjust::ink_through`; Levels, Curves and Brightness/Contrast).
|
||||
Tables([Vec<f32>; INKS]),
|
||||
/// Invert, Posterize or Threshold (`adjust::stored_channels`).
|
||||
Rules(&'a Adjustment),
|
||||
/// Hue/Saturation or Color Balance on C, M and Y as inverted RGB (`adjust::apply_cmy`).
|
||||
Cmy(&'a Adjustment),
|
||||
}
|
||||
|
||||
impl InkOp<'_> {
|
||||
/// The adjusted inks of each pixel of `inks` that has coverage (its fifth value).
|
||||
fn apply(&self, inks: &[[f32; INKS + 1]], depth: SampleType) -> Vec<[f32; INKS]> {
|
||||
let mut out: Vec<[f32; INKS]> = inks.iter().map(|d| [d[0], d[1], d[2], d[3]]).collect();
|
||||
match self {
|
||||
InkOp::Tables(t) => {
|
||||
for ink in &mut out {
|
||||
for (v, table) in ink.iter_mut().zip(t) {
|
||||
*v = adjust::ink_through(table, *v);
|
||||
}
|
||||
}
|
||||
}
|
||||
InkOp::Rules(adj) => out.iter_mut().for_each(|ink| adjust::stored_channels(adj, ColorMode::Cmyk, Some(depth), ink)),
|
||||
InkOp::Cmy(adj) => adjust::apply_cmy(adj, &mut out),
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The ink evaluation of an adjustment layer the native path can composite.
|
||||
fn ink_op(layer: &Layer, depth: SampleType) -> Option<InkOp<'_>> {
|
||||
let LayerContent::Adjustment(adj) = &layer.content else { return None };
|
||||
if !simple(layer) {
|
||||
return None;
|
||||
}
|
||||
match adj {
|
||||
Adjustment::Invert | Adjustment::Posterize { .. } | Adjustment::Threshold { .. } => Some(InkOp::Rules(adj)),
|
||||
_ if adjust::cmy_adjustment(adj) => Some(InkOp::Cmy(adj)),
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
|
||||
let t = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy);
|
||||
Some(InkOp::Tables([t.clone(), t.clone(), t.clone(), t]))
|
||||
}
|
||||
Adjustment::Levels { space: ToneSpace::Cmyk, .. } | Adjustment::Curves { space: ToneSpace::Cmyk, .. } => {
|
||||
Some(InkOp::Tables(adjust::tone_luts_depth(adj, Some(depth))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of a native stack, at the layer's opacity × fill, through its mask.
|
||||
enum Item<'a> {
|
||||
Layer(&'a Surface, f32, Option<&'a LayerMask>),
|
||||
Adjust(InkOp<'a>, f32, Option<&'a LayerMask>),
|
||||
}
|
||||
|
||||
/// Groups nested deeper than this keep the RGB compositor.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group
|
||||
/// at full opacity without a mask is inlined: pass-through groups composite their children straight
|
||||
/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal
|
||||
/// composites associatively; an adjustment inside an isolated group would only see the group's
|
||||
/// own content, so such a group keeps the RGB compositor (`adjustments` false).
|
||||
fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec<Item<'a>>, level: usize) -> Option<()> {
|
||||
if level > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
for layer in layers.iter().filter(|l| l.visible) {
|
||||
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
|
||||
let mask = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
if let LayerContent::Group(g) = &layer.content {
|
||||
let open = g.artboard.is_none()
|
||||
&& alpha >= 1.0
|
||||
&& mask.is_none()
|
||||
&& !layer.clipped
|
||||
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
|
||||
&& !crate::effects::has_effects(layer)
|
||||
&& layer.excluded_channels == 0
|
||||
&& layer.blend_if.is_default()
|
||||
&& layer.advanced.is_default();
|
||||
match layer.blend {
|
||||
BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?,
|
||||
BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?,
|
||||
_ => return None,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let item = match plain(layer) {
|
||||
Some(s) => Item::Layer(s, alpha, mask),
|
||||
None if adjustments => Item::Adjust(ink_op(layer, depth)?, alpha, mask),
|
||||
None => return None,
|
||||
};
|
||||
items.push(item);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// A CMYK document's visible layers, prepared for compositing on their ink values.
|
||||
pub struct InkStack<'a> {
|
||||
items: Vec<Item<'a>>,
|
||||
depth: SampleType,
|
||||
}
|
||||
|
||||
impl<'a> InkStack<'a> {
|
||||
/// `None` unless `doc` is CMYK and every visible layer is a plain raster layer, a plain
|
||||
/// adjustment layer the inks carry exactly, or a group that can be inlined (see the module
|
||||
/// docs and [`collect`]).
|
||||
pub fn new(doc: &'a Document) -> Option<Self> {
|
||||
if doc.mode != ColorMode::Cmyk {
|
||||
return None;
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
collect(&doc.layers, doc.depth, true, &mut items, 0)?;
|
||||
Some(InkStack { items, depth: doc.depth })
|
||||
}
|
||||
|
||||
/// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly).
|
||||
pub fn adjusts(&self) -> bool {
|
||||
self.items.iter().any(|i| matches!(i, Item::Adjust(..)))
|
||||
}
|
||||
|
||||
/// Straight C, M, Y, K and alpha over `rect`, row-major, into `acc` (`src` and `mv` are
|
||||
/// scratch).
|
||||
fn composite(&self, rect: Rect, acc: &mut Vec<[f32; INKS + 1]>, src: &mut Vec<f32>, mv: &mut Vec<f32>) {
|
||||
let n = rect.width() as usize * rect.height() as usize;
|
||||
acc.clear();
|
||||
acc.resize(n, [0.0f32; INKS + 1]);
|
||||
// The mask's value at pixel `i` (1 without one).
|
||||
let masked = |mask: Option<&LayerMask>, mv: &mut Vec<f32>| match mask {
|
||||
Some(m) => m.values_into(rect, mv),
|
||||
None => {
|
||||
mv.clear();
|
||||
mv.resize(n, 1.0);
|
||||
}
|
||||
};
|
||||
for item in &self.items {
|
||||
match item {
|
||||
Item::Layer(surf, opacity, mask) => {
|
||||
let opacity = *opacity;
|
||||
if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) {
|
||||
continue;
|
||||
}
|
||||
let sf = surf.format();
|
||||
let sn = sf.channels();
|
||||
surf.read_region_into(rect, src);
|
||||
masked(*mask, mv);
|
||||
for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) {
|
||||
let sa = if sf.alpha { s.get(INKS).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0);
|
||||
if sa <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let oa = sa + d[INKS] * (1.0 - sa);
|
||||
let keep = d[INKS] * (1.0 - sa);
|
||||
for (c, v) in s.iter().take(INKS).enumerate() {
|
||||
// An opaque pixel replaces what is below exactly (no rounding through the mix).
|
||||
d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa };
|
||||
}
|
||||
d[INKS] = oa;
|
||||
}
|
||||
}
|
||||
Item::Adjust(op, opacity, mask) => {
|
||||
if *opacity <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
masked(*mask, mv);
|
||||
let adjusted = op.apply(acc, self.depth);
|
||||
for ((d, k), ink) in acc.iter_mut().zip(mv.iter()).zip(&adjusted) {
|
||||
let mix = opacity * k.clamp(0.0, 1.0);
|
||||
if d[INKS] <= 0.0 || mix <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (c, v) in ink.iter().enumerate() {
|
||||
d[c] += (v - d[c]) * mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The composite of `doc` over `rect` in `fmt`, worked out in ink values, or `None` when the
|
||||
/// document isn't CMYK or a visible layer needs the full compositor (see the module docs).
|
||||
/// `paper` flattens onto white paper (no ink) and makes the result opaque.
|
||||
pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -> Option<Surface> {
|
||||
if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk || doc.layers.iter().any(|l| l.clipped) {
|
||||
if fmt.mode != ColorMode::Cmyk {
|
||||
return None;
|
||||
}
|
||||
let mut stack = Vec::new();
|
||||
for layer in doc.layers.iter().filter(|l| l.visible) {
|
||||
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
|
||||
stack.push((plain(layer)?, alpha));
|
||||
}
|
||||
let stack = InkStack::new(doc)?;
|
||||
let mut out = Surface::new(fmt);
|
||||
if rect.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let w = rect.width() as usize;
|
||||
let rows = crate::band_rows_for(rect.width(), 0);
|
||||
let (mut acc, mut src, mut data) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
let mut y = rect.y0;
|
||||
while y < rect.y1 {
|
||||
let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1));
|
||||
// Straight C, M, Y, K, alpha per pixel, starting transparent.
|
||||
acc.clear();
|
||||
acc.resize(w * band.height() as usize, [0.0f32; INKS + 1]);
|
||||
for &(surf, opacity) in &stack {
|
||||
if opacity <= 0.0 || (surf.content_bounds().intersect(&band).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) {
|
||||
continue;
|
||||
}
|
||||
let sf = surf.format();
|
||||
let sn = sf.channels();
|
||||
surf.read_region_into(band, &mut src);
|
||||
for (d, s) in acc.iter_mut().zip(src.chunks_exact(sn)) {
|
||||
let sa = if sf.alpha { s[INKS] } else { 1.0 } * opacity;
|
||||
if sa <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let oa = sa + d[INKS] * (1.0 - sa);
|
||||
let keep = d[INKS] * (1.0 - sa);
|
||||
for c in 0..INKS {
|
||||
// An opaque pixel replaces what is below exactly (no rounding through the mix).
|
||||
d[c] = if sa >= 1.0 { s[c] } else { (s[c] * sa + d[c] * keep) / oa };
|
||||
}
|
||||
d[INKS] = oa;
|
||||
}
|
||||
}
|
||||
stack.composite(band, &mut acc, &mut src, &mut mv);
|
||||
data.clear();
|
||||
data.resize(acc.len() * n, 0.0);
|
||||
for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) {
|
||||
|
|
@ -99,3 +269,41 @@ pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -
|
|||
out.prune();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The display composite (straight RGBA, through the document's CMYK profile) of a CMYK document
|
||||
/// whose stack has adjustment layers the inks carry exactly, worked out on the inks; `None` for
|
||||
/// any other document, which the RGB compositor renders. Rows of `tile` render in parallel.
|
||||
pub fn render_cmyk(doc: &Document, rect: Rect, tile: i32) -> Option<Buffer> {
|
||||
let stack = InkStack::new(doc).filter(InkStack::adjusts)?;
|
||||
let cmyk = crate::cmyk_space(doc);
|
||||
let w = rect.width() as usize;
|
||||
let mut out = Buffer::transparent(rect);
|
||||
if w == 0 || rect.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
let rows = tile.max(1) as usize;
|
||||
let band = |i: usize, px: &mut [[f32; 4]]| {
|
||||
let y0 = rect.y0.saturating_add((i * rows) as i32);
|
||||
let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32));
|
||||
let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new());
|
||||
stack.composite(r, &mut acc, &mut src, &mut mv);
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
for (o, p) in px.iter_mut().zip(&acc) {
|
||||
if p[INKS] > 0.0 {
|
||||
let [r, g, b] = photocraft_color::convert::cmyk_to_rgb([p[0], p[1], p[2], p[3]]);
|
||||
*o = [r, g, b, p[INKS]];
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (i, px) in out.px.chunks_mut(w * rows).enumerate() {
|
||||
band(i, px);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
|
|
|||
253
crates/compose/src/native_lab.rs
Normal file
253
crates/compose/src/native_lab.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
//! Lab-native compositing of plain Lab layer stacks.
|
||||
//!
|
||||
//! The compositor works in display RGB, so a Lab document's layers are read through `lab_to_srgb`,
|
||||
//! which clips colours outside the display gamut, and adjustments Photoshop evaluates on L, a and
|
||||
//! b ([`adjust::lab_stored_op`]) can then only see those clipped colours: an inverted or levelled
|
||||
//! saturated colour comes out several levels off. When every visible layer is a plain Lab raster
|
||||
//! layer in Normal mode or such an adjustment layer, the stack is worked out on the stored values
|
||||
//! instead: Normal mixes L, a and b linearly, as Photoshop does in Lab, and an opaque layer keeps
|
||||
//! them exactly. A layer mask (unfeathered) scales the mix.
|
||||
//!
|
||||
//! Stacks with such adjustment layers render for display this way ([`render_lab`]); flattening
|
||||
//! ([`flatten_lab`]) keeps the stored values for any plain stack.
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerMask};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::Buffer;
|
||||
use crate::adjust::{self, LabOp};
|
||||
|
||||
/// Colour channels of a Lab pixel.
|
||||
const LAB: usize = 3;
|
||||
|
||||
/// Whether the compositor would only blend `layer` Normally at its opacity through its layer mask:
|
||||
/// no clipping, vector mask, feathered mask, effects, excluded channels, Blend If or advanced
|
||||
/// blending.
|
||||
fn simple(layer: &Layer) -> bool {
|
||||
layer.blend == BlendMode::Normal
|
||||
&& !layer.clipped
|
||||
&& !crate::masks::has_feather(layer)
|
||||
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
|
||||
&& !crate::effects::has_effects(layer)
|
||||
&& layer.excluded_channels == 0
|
||||
&& layer.blend_if.is_default()
|
||||
&& layer.advanced.is_default()
|
||||
&& layer.video.is_none()
|
||||
}
|
||||
|
||||
/// One step of a native stack, at the layer's opacity × fill, through its mask.
|
||||
enum Item<'a> {
|
||||
Layer(&'a Surface, f32, Option<&'a LayerMask>),
|
||||
Adjust(LabOp<'a>, f32, Option<&'a LayerMask>),
|
||||
}
|
||||
|
||||
/// Groups nested deeper than this keep the RGB compositor.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// `layers`' visible steps, appended to `items`; `None` when one needs the RGB compositor. A group
|
||||
/// at full opacity without a mask is inlined: pass-through groups composite their children straight
|
||||
/// onto the backdrop, and so do isolated (Normal) groups of raster layers alone, since Normal
|
||||
/// composites associatively; an adjustment inside an isolated group would only see the group's
|
||||
/// own content, so such a group keeps the RGB compositor (`adjustments` false).
|
||||
fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: &mut Vec<Item<'a>>, level: usize) -> Option<()> {
|
||||
if level > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
for layer in layers.iter().filter(|l| l.visible) {
|
||||
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
|
||||
let mask = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let item = match &layer.content {
|
||||
LayerContent::Group(g) => {
|
||||
let open = g.artboard.is_none()
|
||||
&& alpha >= 1.0
|
||||
&& mask.is_none()
|
||||
&& !layer.clipped
|
||||
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
|
||||
&& !crate::effects::has_effects(layer)
|
||||
&& layer.excluded_channels == 0
|
||||
&& layer.blend_if.is_default()
|
||||
&& layer.advanced.is_default();
|
||||
match layer.blend {
|
||||
BlendMode::PassThrough if open => collect(&g.children, depth, adjustments, items, level + 1)?,
|
||||
BlendMode::Normal if open => collect(&g.children, depth, false, items, level + 1)?,
|
||||
_ => return None,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
LayerContent::Raster(s) if simple(layer) && s.format().mode == ColorMode::Lab => Item::Layer(s, alpha, mask),
|
||||
LayerContent::Adjustment(adj) if adjustments && simple(layer) => Item::Adjust(adjust::lab_stored_op(adj, Some(depth))?, alpha, mask),
|
||||
_ => return None,
|
||||
};
|
||||
items.push(item);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// A Lab document's visible layers, prepared for compositing on their stored L, a and b.
|
||||
pub struct LabStack<'a> {
|
||||
items: Vec<Item<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> LabStack<'a> {
|
||||
/// `None` unless `doc` is Lab and every visible layer is a plain Lab raster layer, an
|
||||
/// adjustment layer [`adjust::lab_stored_op`] evaluates, or a group that can be inlined (see
|
||||
/// the module docs and [`collect`]).
|
||||
pub fn new(doc: &'a Document) -> Option<Self> {
|
||||
if doc.mode != ColorMode::Lab {
|
||||
return None;
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
collect(&doc.layers, doc.depth, true, &mut items, 0)?;
|
||||
Some(LabStack { items })
|
||||
}
|
||||
|
||||
/// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly).
|
||||
pub fn adjusts(&self) -> bool {
|
||||
self.items.iter().any(|i| matches!(i, Item::Adjust(..)))
|
||||
}
|
||||
|
||||
/// Straight L, a, b and alpha over `rect`, row-major, into `acc` (`src` and `mv` are scratch).
|
||||
fn composite(&self, rect: Rect, acc: &mut Vec<[f32; LAB + 1]>, src: &mut Vec<f32>, mv: &mut Vec<f32>) {
|
||||
let n = rect.width() as usize * rect.height() as usize;
|
||||
acc.clear();
|
||||
acc.resize(n, [0.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL, 0.0]);
|
||||
// The mask's value at each pixel (1 without one).
|
||||
let masked = |mask: Option<&LayerMask>, mv: &mut Vec<f32>| match mask {
|
||||
Some(m) => m.values_into(rect, mv),
|
||||
None => {
|
||||
mv.clear();
|
||||
mv.resize(n, 1.0);
|
||||
}
|
||||
};
|
||||
for item in &self.items {
|
||||
match item {
|
||||
Item::Layer(surf, opacity, mask) => {
|
||||
let opacity = *opacity;
|
||||
if opacity <= 0.0 || (surf.content_bounds().intersect(&rect).is_empty() && surf.default_pixel().get(LAB).is_none_or(|a| *a <= 0.0)) {
|
||||
continue;
|
||||
}
|
||||
let sf = surf.format();
|
||||
let sn = sf.channels();
|
||||
surf.read_region_into(rect, src);
|
||||
masked(*mask, mv);
|
||||
for ((d, s), k) in acc.iter_mut().zip(src.chunks_exact(sn)).zip(mv.iter()) {
|
||||
let sa = if sf.alpha { s.get(LAB).copied().unwrap_or(1.0) } else { 1.0 } * opacity * k.clamp(0.0, 1.0);
|
||||
if sa <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let oa = sa + d[LAB] * (1.0 - sa);
|
||||
let keep = d[LAB] * (1.0 - sa);
|
||||
for (c, v) in s.iter().take(LAB).enumerate() {
|
||||
// An opaque pixel replaces what is below exactly (no rounding through the mix).
|
||||
d[c] = if sa >= 1.0 { *v } else { (v * sa + d[c] * keep) / oa };
|
||||
}
|
||||
d[LAB] = oa;
|
||||
}
|
||||
}
|
||||
Item::Adjust(op, opacity, mask) => {
|
||||
if *opacity <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
masked(*mask, mv);
|
||||
for (d, k) in acc.iter_mut().zip(mv.iter()) {
|
||||
let mix = opacity * k.clamp(0.0, 1.0);
|
||||
if d[LAB] <= 0.0 || mix <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut lab = [d[0], d[1], d[2]];
|
||||
op(&mut lab);
|
||||
for (c, v) in lab.iter().enumerate() {
|
||||
d[c] += (v - d[c]) * mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The composite of `doc` over `rect` in `fmt`, worked out on stored L, a and b, or `None` when
|
||||
/// the document isn't Lab, `fmt` isn't, or a visible layer needs the full compositor (see the
|
||||
/// module docs). `white` flattens onto white (L 100, neutral a and b) and makes the result opaque.
|
||||
pub fn flatten_lab(doc: &Document, rect: Rect, fmt: PixelFormat, white: bool) -> Option<Surface> {
|
||||
if fmt.mode != ColorMode::Lab {
|
||||
return None;
|
||||
}
|
||||
let stack = LabStack::new(doc)?;
|
||||
let mut out = Surface::new(fmt);
|
||||
if rect.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let empty = out.default_pixel();
|
||||
let rows = crate::band_rows_for(rect.width(), 0);
|
||||
let (mut acc, mut src, mut mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
let mut y = rect.y0;
|
||||
while y < rect.y1 {
|
||||
let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1));
|
||||
stack.composite(band, &mut acc, &mut src, &mut mv);
|
||||
data.clear();
|
||||
data.resize(acc.len() * n, 0.0);
|
||||
for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) {
|
||||
let a = p[LAB];
|
||||
if white {
|
||||
let paper = [1.0, adjust::LAB_NEUTRAL, adjust::LAB_NEUTRAL];
|
||||
for c in 0..LAB {
|
||||
o[c] = p[c] * a + paper[c] * (1.0 - a);
|
||||
}
|
||||
if fmt.alpha {
|
||||
o[LAB] = 1.0;
|
||||
}
|
||||
} else if a <= 0.0 {
|
||||
o.copy_from_slice(&empty[..n]);
|
||||
} else {
|
||||
o[..LAB].copy_from_slice(&p[..LAB]);
|
||||
if fmt.alpha {
|
||||
o[LAB] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.write_region(band, &data);
|
||||
y = band.y1;
|
||||
}
|
||||
out.prune();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The display composite (straight RGBA) of a Lab document whose stack has adjustment layers that
|
||||
/// work on L, a and b, worked out on the stored values; `None` for any other document, which the
|
||||
/// RGB compositor renders. Rows of `tile` render in parallel.
|
||||
pub fn render_lab(doc: &Document, rect: Rect, tile: i32) -> Option<Buffer> {
|
||||
let stack = LabStack::new(doc).filter(LabStack::adjusts)?;
|
||||
let w = rect.width() as usize;
|
||||
let mut out = Buffer::transparent(rect);
|
||||
if w == 0 || rect.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
let rows = tile.max(1) as usize;
|
||||
let band = |i: usize, px: &mut [[f32; 4]]| {
|
||||
let y0 = rect.y0.saturating_add((i * rows) as i32);
|
||||
let r = Rect::new(rect.x0, y0, rect.x1, y0.saturating_add((px.len() / w) as i32));
|
||||
let (mut acc, mut src, mut mv) = (Vec::new(), Vec::new(), Vec::new());
|
||||
stack.composite(r, &mut acc, &mut src, &mut mv);
|
||||
for (o, p) in px.iter_mut().zip(&acc) {
|
||||
if p[LAB] > 0.0 {
|
||||
let [r, g, b] = photocraft_color::convert::lab_to_srgb([p[0] * 100.0, p[1] * 255.0 - 128.0, p[2] * 255.0 - 128.0]);
|
||||
*o = [r, g, b, p[LAB]];
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.px.par_chunks_mut(w * rows).enumerate().for_each(|(i, px)| band(i, px));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (i, px) in out.px.chunks_mut(w * rows).enumerate() {
|
||||
band(i, px);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
//! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP
|
||||
//! document would cost seconds and gigabytes.
|
||||
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size, SmartObject};
|
||||
use photocraft_geom::{Affine, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k).
|
||||
|
|
@ -60,11 +60,30 @@ fn shrink_layer(l: &mut Layer, k: u32) {
|
|||
}
|
||||
LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)),
|
||||
LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)),
|
||||
LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)),
|
||||
LayerContent::Smart(so) => shrink_smart(so, k),
|
||||
LayerContent::Adjustment(_) | LayerContent::Fill(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales a smart object with its proxy: the cache and filter mask like any surface, and the
|
||||
/// placement too. A preview that adds a smart filter re-renders the source through the placement;
|
||||
/// left in full-size document coordinates, that render came out `k` times too large on the proxy
|
||||
/// canvas, so the Curves preview on a smart object zoomed in (#5766).
|
||||
fn shrink_smart(so: &mut SmartObject, k: u32) {
|
||||
so.cache = so.cache.as_ref().map(|s| downsample(s, k));
|
||||
if let Some(m) = &mut so.filter_mask {
|
||||
m.surface = downsample(&m.surface, k);
|
||||
}
|
||||
let s = 1.0 / f64::from(k);
|
||||
so.transform = Affine::scale(s).mul(&so.transform);
|
||||
// Row-major source → document map: scaling the document side scales its first two rows.
|
||||
if let Some(h) = &mut so.perspective {
|
||||
for v in h.iter_mut().take(6) {
|
||||
*v *= s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). The
|
||||
/// selection scales with it, so a filter previewed on the proxy stays inside the (feathered)
|
||||
/// selection exactly as the full-size result does.
|
||||
|
|
@ -166,6 +185,33 @@ mod tests {
|
|||
assert!(proxy_faithful(&doc));
|
||||
}
|
||||
|
||||
/// #5766: a smart object re-rendered on the proxy (a smart filter preview) goes through its
|
||||
/// placement, which must shrink with the canvas, cache and filter mask.
|
||||
#[test]
|
||||
fn proxy_scales_smart_object_placement() {
|
||||
use photocraft_doc::SmartSource;
|
||||
let mut doc = Document::new("smart", Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
|
||||
let mut cache = Surface::new(doc.pixel_format());
|
||||
cache.fill_rect(Rect::new(16, 16, 32, 48), &[0.0, 0.0, 0.0, 1.0]);
|
||||
let source = SmartSource::Embedded { file_name: "s.png".into(), bytes: std::sync::Arc::new(Vec::new()) };
|
||||
let mut sm = SmartObject::new(source, Affine::translate(16.0, 16.0).mul(&Affine::scale(2.0)), Some(cache));
|
||||
let mut mask = LayerMask::hide_all();
|
||||
mask.surface.fill_rect(Rect::new(16, 16, 32, 32), &[1.0]);
|
||||
sm.filter_mask = Some(mask);
|
||||
sm.perspective = Some([2.0, 0.0, 16.0, 0.0, 2.0, 16.0, 0.0, 0.0, 1.0]);
|
||||
doc.layers.push(Layer::new("smart", LayerContent::Smart(sm)));
|
||||
let p = proxy_document(&doc, 4);
|
||||
let LayerContent::Smart(sm) = &p.layers[0].content else { panic!("a smart object") };
|
||||
assert_eq!(sm.transform, Affine { m: [0.5, 0.0, 0.0, 0.5, 4.0, 4.0] });
|
||||
assert_eq!(sm.perspective, Some([0.5, 0.0, 4.0, 0.0, 0.5, 4.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(sm.cache.as_ref().unwrap().content_bounds(), Rect::new(4, 4, 8, 12));
|
||||
let mask = &sm.filter_mask.as_ref().unwrap().surface;
|
||||
assert_eq!((mask.pixel(4, 4)[0], mask.pixel(8, 8)[0]), (1.0, 0.0));
|
||||
// The document keeps its full-size placement.
|
||||
let LayerContent::Smart(orig) = &doc.layers[0].content else { panic!("a smart object") };
|
||||
assert_eq!(orig.transform.m[4], 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_scales_the_selection() {
|
||||
let mut doc = Document::with_background("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::adjust::{CurvePoint, LevelsChannel};
|
||||
use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace};
|
||||
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerMask};
|
||||
use photocraft_geom::Size;
|
||||
|
||||
|
|
@ -606,6 +606,142 @@ fn a_16_bit_lab_invert_layer_keeps_greys_neutral() {
|
|||
assert!((lab[0] - 75.0).abs() < 0.1 && lab[1].abs() < 0.05 && lab[2].abs() < 0.05, "{lab:?}");
|
||||
}
|
||||
|
||||
/// A CMYK document of one opaque raster layer holding `ink` everywhere, at `depth`.
|
||||
fn cmyk_doc(ink: [f32; 4], depth: SampleType) -> Document {
|
||||
let mut d = Document::new("c", Size::new(4, 4), ColorMode::Cmyk, depth);
|
||||
let mut l = Layer::raster("bg", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[ink[0], ink[1], ink[2], ink[3], 1.0]);
|
||||
d.layers.push(l);
|
||||
d
|
||||
}
|
||||
|
||||
/// Adjustment layers Photoshop evaluates on the inks composite on the document's own inks, not on
|
||||
/// a separation of the RGB composite: exact for Invert, Posterize, Threshold, Brightness/Contrast
|
||||
/// and CMYK Levels / Curves (photoshop corpus cmyk8 and cmyk16 oracles), through opacity and an
|
||||
/// (unfeathered) mask as linear ink mixes.
|
||||
#[test]
|
||||
fn cmyk_ink_adjustments_composite_on_the_documents_inks() {
|
||||
use photocraft_color::convert::cmyk_to_rgb;
|
||||
let ink = [0.2, 0.4, 0.6, 0.1];
|
||||
let rgb = |i: [f32; 4]| {
|
||||
let c = cmyk_to_rgb(i);
|
||||
[c[0], c[1], c[2], 1.0]
|
||||
};
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let mut d = cmyk_doc(ink, depth);
|
||||
// Stored inks, as the document's surface rounds them.
|
||||
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
|
||||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||||
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| 1.0 - v))), "{depth:?} invert");
|
||||
// Half opacity and a half mask mix the inks linearly: a quarter of the way.
|
||||
d.layers[1].opacity = 0.5;
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.5]);
|
||||
d.layers[1].mask = Some(m);
|
||||
let k = 0.5 * d.layers[1].mask.as_ref().unwrap().value(1, 1);
|
||||
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| v + (1.0 - 2.0 * v) * k))), "{depth:?} mixed");
|
||||
// Brightness/Contrast curves each ink's stored value.
|
||||
let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, true);
|
||||
d.layers[1] = Layer::new("bc", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }));
|
||||
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| adjust::ink_through(&table, v)))), "{depth:?} brightness/contrast");
|
||||
// Flattening keeps the adjusted inks exactly, with no trip through RGB.
|
||||
let flat = flatten_to_surface(&d, d.pixel_format(), None);
|
||||
let got = flat.pixel(2, 2);
|
||||
for c in 0..4 {
|
||||
let want = adjust::ink_through(&table, stored[c]);
|
||||
let q = if depth == SampleType::U8 { 255.0 } else { 65535.0 };
|
||||
assert!((got[c] - want).abs() <= 0.5 / q + 1e-6, "{depth:?} flattened ink {c}: {} vs {want}", got[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only stacks with ink adjustments take the ink compositor for display: plain CMYK layers keep
|
||||
/// the RGB compositor (which the GPU matches), and so does any stack with an adjustment the inks
|
||||
/// don't carry or a layer they can't composite.
|
||||
#[test]
|
||||
fn only_cmyk_stacks_with_ink_adjustments_render_on_their_inks() {
|
||||
let r = Rect::new(0, 0, 4, 4);
|
||||
let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8);
|
||||
assert!(native::render_cmyk(&d, r, 64).is_none(), "plain layers");
|
||||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||||
assert!(native::render_cmyk(&d, r, 64).is_some());
|
||||
let exposure = Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 };
|
||||
d.layers.push(Layer::new("exposure", LayerContent::Adjustment(exposure)));
|
||||
assert!(native::render_cmyk(&d, r, 64).is_none(), "an adjustment the inks don't carry");
|
||||
d.layers.pop();
|
||||
d.layers[1].blend = BlendMode::Multiply;
|
||||
assert!(native::render_cmyk(&d, r, 64).is_none(), "a blend mode");
|
||||
d.layers[1].blend = BlendMode::Normal;
|
||||
let mut rgb = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8);
|
||||
rgb.mode = ColorMode::Rgb;
|
||||
rgb.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||||
assert!(native::render_cmyk(&rgb, r, 64).is_none(), "not a CMYK document");
|
||||
// Tiles render alike whatever their size.
|
||||
let whole = render_tiled(&d, d.bounds(), 64);
|
||||
assert_eq!(render_tiled(&d, d.bounds(), 1).px, whole.px);
|
||||
}
|
||||
|
||||
/// Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with the
|
||||
/// black ink kept (photoshop corpus cmyk16 hue-saturation.psd: every ink within half a level).
|
||||
#[test]
|
||||
fn cmyk_hue_saturation_and_color_balance_keep_the_black_ink() {
|
||||
use photocraft_color::convert::cmyk_to_rgb;
|
||||
let ranges = photocraft_doc::adjust::HueRange::defaults();
|
||||
let hue = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: ranges.clone() };
|
||||
let balance =
|
||||
Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true };
|
||||
for adj in [hue, balance] {
|
||||
assert!(adjust::cmy_adjustment(&adj) && adjust::cmyk_channel_adjustment(&adj));
|
||||
let mut d = cmyk_doc([0.2, 0.7, 0.1, 0.3], SampleType::U16);
|
||||
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
|
||||
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone())));
|
||||
// The RGB adjustment on the inks' complement, black untouched.
|
||||
let mut rgb = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0 - stored[0], 1.0 - stored[1], 1.0 - stored[2], 1.0]);
|
||||
adjust::apply(&adj, &mut rgb);
|
||||
let want = [1.0 - rgb.px[0][0], 1.0 - rgb.px[0][1], 1.0 - rgb.px[0][2], stored[3]];
|
||||
assert!(native::render_cmyk(&d, d.bounds(), 64).is_some(), "{}", adj.label());
|
||||
let c = cmyk_to_rgb(want);
|
||||
assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "{}", adj.label());
|
||||
let flat = flatten_to_surface(&d, d.pixel_format(), None);
|
||||
let got = flat.pixel(1, 1);
|
||||
assert!((got[3] - stored[3]).abs() < 1e-4, "{}: black kept", adj.label());
|
||||
assert!((0..3).all(|i| (got[i] - want[i]).abs() < 1e-3), "{}: {got:?} vs {want:?}", adj.label());
|
||||
}
|
||||
// Colorize separates its colour again (black included), so it stays on the screen colour.
|
||||
let colorize = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges };
|
||||
assert!(!adjust::cmy_adjustment(&colorize) && !adjust::cmyk_channel_adjustment(&colorize));
|
||||
}
|
||||
|
||||
/// Groups at full opacity without a mask are inlined: a pass-through group's adjustment reaches
|
||||
/// the layers below it, and an isolated group of raster layers composites as its children would.
|
||||
/// An isolated group's adjustment (which only sees the group) or a translucent group keeps the
|
||||
/// RGB compositor.
|
||||
#[test]
|
||||
fn cmyk_ink_compositing_inlines_open_groups() {
|
||||
use photocraft_color::convert::cmyk_to_rgb;
|
||||
let r = Rect::new(0, 0, 4, 4);
|
||||
let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U16);
|
||||
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
|
||||
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
|
||||
g.blend = BlendMode::PassThrough;
|
||||
d.layers.push(g);
|
||||
let c = cmyk_to_rgb(stored.map(|v| 1.0 - v));
|
||||
assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "a pass-through group's Invert reaches the inks below");
|
||||
d.layers[1].blend = BlendMode::Normal;
|
||||
assert!(native::render_cmyk(&d, r, 64).is_none(), "an isolated group's adjustment");
|
||||
d.layers[1].blend = BlendMode::PassThrough;
|
||||
d.layers[1].opacity = 0.5;
|
||||
assert!(native::render_cmyk(&d, r, 64).is_none(), "a translucent group");
|
||||
// An isolated group of raster layers, with an adjustment above it, still composites on the inks.
|
||||
let mut top = Layer::raster("top", d.pixel_format());
|
||||
top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 4), &[0.9, 0.0, 0.0, 0.0, 1.0]);
|
||||
d.layers[1] = Layer::group("raster", vec![top]);
|
||||
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
|
||||
assert!(native::render_cmyk(&d, r, 64).is_some());
|
||||
let c = cmyk_to_rgb(stored.map(|v| 1.0 - v));
|
||||
assert!(close4(px(&d, 3, 1), [c[0], c[1], c[2], 1.0]), "outside the group's layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_document_renders_via_rgb() {
|
||||
let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8);
|
||||
|
|
@ -1728,6 +1864,43 @@ fn modern_contrast_matches_photoshop() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Over the whole slider range the curve stays monotone and inside 0..1, lowering it undoes
|
||||
/// raising it by as much, and odd input never panics.
|
||||
#[test]
|
||||
fn modern_brightness_is_monotone_and_lowering_inverts_raising() {
|
||||
for b in (-150..=150).step_by(5).map(|b| b as f32) {
|
||||
let mut prev = 0.0f32;
|
||||
for k in 0..=1020 {
|
||||
let v = adjust::modern_brightness(k as f32 / 1020.0, b);
|
||||
assert!((0.0..=1.0).contains(&v) && v >= prev - 1e-6, "brightness {b} at {k}/1020: {v} after {prev}");
|
||||
prev = v;
|
||||
}
|
||||
assert!(
|
||||
(adjust::modern_brightness(1.0, b) - 1.0).abs() < 1e-5 && adjust::modern_brightness(0.0, b).abs() < 1e-5,
|
||||
"brightness {b} pins black and white"
|
||||
);
|
||||
for k in 0..=50 {
|
||||
let x = k as f32 / 50.0;
|
||||
let back = adjust::modern_brightness(adjust::modern_brightness(x, b), -b);
|
||||
assert!((back - x).abs() < 2e-3, "brightness {b}: {x} -> {back}");
|
||||
}
|
||||
}
|
||||
for v in [f32::NAN, f32::INFINITY, -3.0, 7.0] {
|
||||
let _ = adjust::modern_brightness(v, 40.0);
|
||||
let _ = adjust::modern_brightness(0.5, v);
|
||||
}
|
||||
assert_eq!(adjust::modern_lut(0.0, 0.0).len(), 4096);
|
||||
// The table agrees with the curves it samples, lowered (inverted in one pass) or raised.
|
||||
for (b, c) in [(-50.0, 30.0), (-150.0, -50.0), (40.0, 0.0), (0.0, 100.0)] {
|
||||
let t = adjust::modern_lut(b, c);
|
||||
for k in (0..4096).step_by(37) {
|
||||
let x = k as f32 / 4095.0;
|
||||
let want = adjust::modern_contrast(adjust::modern_brightness(x, b), c);
|
||||
assert!((t[k] - want).abs() < 1e-3, "({b}, {c}) at {k}: {} vs {want}", t[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modern_brightness_matches_photoshop() {
|
||||
let cases: [(f32, [u8; 10]); 2] = [(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255])];
|
||||
|
|
@ -1736,8 +1909,48 @@ fn modern_brightness_matches_photoshop() {
|
|||
for (i, &x) in xs.iter().enumerate() {
|
||||
let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0;
|
||||
let err = (got - out[i] as f32).abs();
|
||||
// The brightness roll-off is a spline; our fit is close but approximate at the top.
|
||||
assert!(err <= 6.0, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]);
|
||||
// Within about a level of Photoshop's (rounded) output everywhere.
|
||||
assert!(err <= 1.2, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]);
|
||||
}
|
||||
}
|
||||
// Photoshop 27.11's brightness +25 with contrast +30 (photoshop corpus rgb8
|
||||
// brightness-contrast.psd): what the oracle's levels become.
|
||||
let oracle = [
|
||||
(8, 7),
|
||||
(24, 23),
|
||||
(48, 49),
|
||||
(72, 78),
|
||||
(88, 99),
|
||||
(104, 120),
|
||||
(128, 154),
|
||||
(144, 174),
|
||||
(160, 192),
|
||||
(176, 208),
|
||||
(192, 222),
|
||||
(200, 228),
|
||||
(216, 239),
|
||||
(232, 247),
|
||||
(240, 250),
|
||||
(248, 253),
|
||||
];
|
||||
for (x, want) in oracle {
|
||||
let got = adjust::modern_contrast(adjust::modern_brightness(x as f32 / 255.0, 25.0), 30.0) * 255.0;
|
||||
assert!((got - want as f32).abs() <= 1.0, "+25/+30 x={x}: got {got:.2} want {want}");
|
||||
}
|
||||
// psd-tools' brightnesscontrast_rgb.psd masks four layers to their own columns: the brightness
|
||||
// each applies, read back through its (exact) contrast curve.
|
||||
// (brightness, tolerance in levels, [(input level, Photoshop's output level)])
|
||||
type Measured = (f32, f32, &'static [(u32, f32)]);
|
||||
let measured: [Measured; 4] = [
|
||||
(-95.0, 1.5, &[(24, 13.3), (64, 35.1), (96, 52.9), (128, 69.7), (160, 89.6), (192, 114.4), (224, 151.6)]),
|
||||
(-31.0, 1.5, &[(24, 20.3), (64, 53.4), (96, 79.0), (128, 105.6), (160, 132.3), (192, 161.8), (224, 197.4)]),
|
||||
(23.0, 1.5, &[(24, 27.9), (64, 74.0), (96, 110.9), (128, 147.7), (160, 182.9), (192, 213.7), (224, 239.6)]),
|
||||
(150.0, 6.0, &[(24, 60.3), (64, 162.1), (96, 214.5), (128, 238.4), (160, 248.9)]),
|
||||
];
|
||||
for (b, tol, points) in measured {
|
||||
for &(x, want) in points {
|
||||
let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0;
|
||||
assert!((got - want).abs() <= tol, "brightness {b} x={x}: got {got:.1} want {want}");
|
||||
}
|
||||
}
|
||||
// Endpoints and the zero slider are exact.
|
||||
|
|
@ -1748,7 +1961,7 @@ fn modern_brightness_matches_photoshop() {
|
|||
}
|
||||
|
||||
// Levels ground truth from Adobe Photoshop 2026 (adjustLevels on a 0..255 ramp). Verifies our
|
||||
// `levels()` matches the real app, including the gamma>1 soft shadow toe (initial slope 2^gamma).
|
||||
// `levels()` matches the real app, including the gamma>1 shadow toe (initial slope 2^gamma).
|
||||
#[test]
|
||||
fn levels_matches_photoshop() {
|
||||
let ident = |g: f32| LevelsChannel { in_black: 0.0, in_white: 1.0, gamma: g, out_black: 0.0, out_white: 1.0 };
|
||||
|
|
@ -1764,8 +1977,8 @@ fn levels_matches_photoshop() {
|
|||
let want = ((x as f32 / 255.0).powi(2) * 255.0).round();
|
||||
assert_eq!(got, want, "levels gamma 0.5 x={x}");
|
||||
}
|
||||
// Endpoints pinned; identity is identity. (The gamma>1 toe's soft-min leaves white within a
|
||||
// few parts in 1e5 of 1.0 — invisible at any bit depth; exact at 8-bit.)
|
||||
// Endpoints pinned; identity is identity. (The gamma>1 toe ends before white, so white
|
||||
// stays exactly 1.0.)
|
||||
assert_eq!(adjust::levels(&ident(2.5), 0.0), 0.0);
|
||||
assert_eq!((adjust::levels(&ident(2.5), 1.0) * 255.0).round(), 255.0);
|
||||
assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4);
|
||||
|
|
@ -1806,6 +2019,86 @@ fn levels_work_on_whole_levels() {
|
|||
}
|
||||
}
|
||||
|
||||
// Every Levels step lands on a whole level in 8 bits, and the gamma's shadow toe starts at
|
||||
// slope 2^gamma (`adjust::levels_gamma`). Level → Photoshop's level, exact, from:
|
||||
// - psd-tools levels_rgb.psd, Levels 2 (each channel, then the master): master 0..213, gamma
|
||||
// 1.22, output 48..255; R gamma 0.47 to 0..194; B 61..255, gamma 0.47, to 0..213. Narrow
|
||||
// output ranges merge neighbouring levels (R 43..48 → 58, G 8, 9 → 63, B 89, 90 → 54).
|
||||
// - the photoshop corpus's rgb8 levels.psd: master 15..230, gamma 1.3, output 10..245.
|
||||
// - psd-tools levels_grayscale.psd, Levels 3: 44..214, gamma 1.78 (52 → 37 is one level off).
|
||||
#[test]
|
||||
fn levels_steps_match_photoshop() {
|
||||
let ch = |ib: f32, iw: f32, gamma: f32, ob: f32, ow: f32| LevelsChannel {
|
||||
in_black: ib / 255.0,
|
||||
in_white: iw / 255.0,
|
||||
gamma,
|
||||
out_black: ob / 255.0,
|
||||
out_white: ow / 255.0,
|
||||
};
|
||||
let id = LevelsChannel::default();
|
||||
let levels = |master: LevelsChannel, per_channel: [LevelsChannel; 3]| Adjustment::Levels {
|
||||
master,
|
||||
per_channel,
|
||||
space: Default::default(),
|
||||
black: LevelsChannel::default(),
|
||||
};
|
||||
let run = |adj: &Adjustment, xs: &[u8]| {
|
||||
let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect();
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px };
|
||||
adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8));
|
||||
b.px
|
||||
};
|
||||
let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]);
|
||||
let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200];
|
||||
let want: [[u8; 21]; 3] = [
|
||||
[48, 48, 48, 50, 50, 58, 58, 58, 63, 63, 69, 72, 76, 79, 79, 86, 89, 100, 123, 153, 174],
|
||||
[51, 63, 63, 72, 72, 103, 106, 109, 116, 122, 133, 141, 145, 149, 150, 160, 165, 178, 204, 228, 244],
|
||||
[48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 50, 51, 54, 54, 54, 63, 63, 76, 102, 136, 164],
|
||||
];
|
||||
for (k, p) in run(&rgb, &xs).iter().enumerate() {
|
||||
for c in 0..3 {
|
||||
assert_eq!((p[c] * 255.0).round(), f32::from(want[c][k]), "levels_rgb channel {c} at {}", xs[k]);
|
||||
}
|
||||
}
|
||||
let single = |c: LevelsChannel| levels(c, [id.clone(), id.clone(), id.clone()]);
|
||||
let oracles: [(Adjustment, &[u8], &[u8]); 2] = [
|
||||
(
|
||||
single(ch(15.0, 230.0, 1.3, 10.0, 245.0)),
|
||||
&[0, 21, 22, 49, 82, 118, 154, 176, 195, 215, 235, 251],
|
||||
&[10, 25, 27, 66, 106, 144, 178, 198, 215, 232, 245, 245],
|
||||
),
|
||||
(
|
||||
single(ch(44.0, 214.0, 1.78, 0.0, 255.0)),
|
||||
&[44, 45, 46, 47, 48, 49, 50, 51, 53, 55, 57, 60, 65, 70, 80, 100, 150, 200, 214],
|
||||
&[0, 7, 10, 17, 20, 26, 29, 35, 43, 51, 58, 66, 79, 89, 107, 137, 196, 243, 255],
|
||||
),
|
||||
];
|
||||
for (adj, xs, want) in &oracles {
|
||||
for ((p, x), w) in run(adj, xs).iter().zip(*xs).zip(*want) {
|
||||
assert_eq!((p[0] * 255.0).round(), f32::from(*w), "levels at {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The shadow toe stays a well-behaved curve for any gamma: from 0 to 1, rising, and without a
|
||||
// jump where it meets the power curve.
|
||||
#[test]
|
||||
fn levels_gamma_toe_is_monotone_for_any_gamma() {
|
||||
for g in [1.0001, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] {
|
||||
let mut last = 0.0f32;
|
||||
for k in 0..=4096 {
|
||||
let u = k as f32 / 4096.0;
|
||||
let t = adjust::levels_gamma(u, g);
|
||||
assert!(t.is_finite() && (0.0..=1.0 + 1e-6).contains(&t), "gamma {g} at {u}: {t}");
|
||||
assert!(t >= last - 1e-6, "gamma {g} falls at {u}: {last} -> {t}");
|
||||
// Never steeper than the toe's initial slope 2^g.
|
||||
assert!(k == 0 || t - last <= 2f32.powf(g) / 4096.0 + 1e-4, "gamma {g} jumps at {u}: {last} -> {t}");
|
||||
last = t;
|
||||
}
|
||||
assert!((adjust::levels_gamma(1.0, g) - 1.0).abs() < 1e-6, "gamma {g}: white");
|
||||
}
|
||||
}
|
||||
|
||||
// The oracle corpus' rgb32 levels.psd: input 15..230, gamma 1.3, output 10..245. In a
|
||||
// 32-bit document neither range clips and the gamma is a plain power curve mirrored below black:
|
||||
// 0.0497 → 3 (the clipped curve gives 10), 0.9473 → 255 (245). Integer documents still clip.
|
||||
|
|
@ -2324,9 +2617,10 @@ fn photo_filter_matches_photoshop() {
|
|||
}
|
||||
}
|
||||
// The 32-bit file picks the same numbers as linear values (Lab 81.5, 9.41, 82.19). Its fit is
|
||||
// looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply).
|
||||
// looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply), and Photoshop's
|
||||
// results there are unclipped: above 1 and below 0.
|
||||
let warming32 = [0.966_445_6, 0.761_806_3, 0.0];
|
||||
for (v, ps) in [([0.2159, 0.2159, 0.2159], [66.0, 53.0, 40.0]), ([1.0, 1.0, 0.0], [255.0, 251.0, 0.0]), ([1.0, 0.0, 0.0], [255.0, 0.0, 0.0])] {
|
||||
for (v, ps) in [([0.2159, 0.2159, 0.2159], [65.7, 53.4, 39.5]), ([1.0, 1.0, 0.0], [271.7, 250.9, -9.0]), ([1.0, 0.0, 0.0], [262.2, -2.0, -1.2])] {
|
||||
let got = filter(warming32, SampleType::F32, v);
|
||||
assert!(got.iter().zip(ps).all(|(g, p)| (g - p).abs() <= 2.5), "F32 {v:?}: got {got:?} want {ps:?}");
|
||||
}
|
||||
|
|
@ -2530,15 +2824,95 @@ fn lab_documents_invert_and_posterize_l_a_and_b() {
|
|||
let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v));
|
||||
assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}");
|
||||
// Posterize, three levels: the same steps on L, a and b (here all land on the middle
|
||||
// step: L ≈ 50 and a, b ≈ -1, a neutral grey).
|
||||
// step: L ≈ 50 and a, b ≈ -1 (0 at 16 bits, whose middle level is 128), a neutral grey).
|
||||
let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth);
|
||||
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize(v, 3));
|
||||
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize_in(v, 3, Some(depth)));
|
||||
let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0];
|
||||
assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}");
|
||||
near3(post, want, 1.0, &format!("{depth:?} posterize"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lab document of one opaque layer holding stored L, a, b `lab` (normalised as Lab surfaces
|
||||
/// store them), with `adjustments` above it.
|
||||
fn lab_stack(lab: [f32; 3], depth: SampleType, adjustments: Vec<Adjustment>) -> Document {
|
||||
let mut doc = Document::new("lab", Size::new(4, 4), ColorMode::Lab, depth);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut layer = Layer::raster("bg", fmt);
|
||||
if let Some(s) = layer.surface_mut() {
|
||||
s.fill_rect(Rect::new(0, 0, 4, 4), &[lab[0], lab[1], lab[2], 1.0][..fmt.channels()]);
|
||||
}
|
||||
doc.layers.push(layer);
|
||||
doc.layers.extend(adjustments.into_iter().map(|a| Layer::new("adj", LayerContent::Adjustment(a))));
|
||||
doc
|
||||
}
|
||||
|
||||
/// Display RGB of stored Lab `n`.
|
||||
fn lab_display(n: [f32; 3]) -> [f32; 3] {
|
||||
photocraft_color::convert::lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
|
||||
}
|
||||
|
||||
// A saturated purple far outside sRGB (L 60, a +90, b -90). Read through display RGB it clips,
|
||||
// so Invert and Levels on L, a and b saw the clipped colour: the photoshop corpus's lab8 and
|
||||
// lab16 invert.psd were up to 11 levels off there. Worked out on the stored values, Invert gives
|
||||
// the bytes' complements (L 153 → 102, a 218 → 37, b 38 → 217), as Photoshop does.
|
||||
#[test]
|
||||
fn lab_adjustments_work_on_the_stored_values() {
|
||||
let byte = |v: u8| f32::from(v) / 255.0;
|
||||
let doc = lab_stack([byte(153), byte(218), byte(38)], SampleType::U8, vec![Adjustment::Invert]);
|
||||
let p = px(&doc, 1, 1);
|
||||
let want = lab_display([byte(102), byte(37), byte(217)]);
|
||||
near3([p[0], p[1], p[2]], want, 1e-4, "inverted out-of-gamut purple");
|
||||
// Through clipped display RGB the result is another colour.
|
||||
let clipped = photocraft_color::convert::srgb_to_lab(lab_display([byte(153), byte(218), byte(38)]));
|
||||
let via_rgb = lab_display([1.0 - clipped[0] / 100.0, (-clipped[1] - 1.0 + 128.0) / 255.0, (-clipped[2] - 1.0 + 128.0) / 255.0]);
|
||||
assert!(via_rgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 4.0 / 255.0), "the clipped path differs: {via_rgb:?} vs {want:?}");
|
||||
// Levels on L only (a Lab record), half masked by opacity: L 0.6 → 0.6^(1/2) mixed halfway.
|
||||
let ch = LevelsChannel { gamma: 2.0, ..Default::default() };
|
||||
let levels = Adjustment::Levels {
|
||||
master: LevelsChannel::default(),
|
||||
per_channel: [ch.clone(), LevelsChannel::default(), LevelsChannel::default()],
|
||||
space: ToneSpace::Lab,
|
||||
black: LevelsChannel::default(),
|
||||
};
|
||||
let mut doc = lab_stack([0.6, byte(218), byte(38)], SampleType::U16, vec![levels]);
|
||||
if let Some(l) = doc.layers.last_mut() {
|
||||
l.opacity = 0.5;
|
||||
}
|
||||
let p = px(&doc, 1, 1);
|
||||
let l = 0.6 + (adjust::levels_q(&ch, 0.6, Some(32768.0)) - 0.6) * 0.5;
|
||||
near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity");
|
||||
}
|
||||
|
||||
// Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB
|
||||
// compositor clipped them on the way through display RGB).
|
||||
#[test]
|
||||
fn lab_flatten_keeps_the_stored_values() {
|
||||
let lab = [0.6, 218.0 / 255.0, 38.0 / 255.0];
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let doc = lab_stack(lab, depth, vec![]);
|
||||
let s = flatten_to_surface(&doc, doc.pixel_format(), None);
|
||||
let got = s.pixel(2, 2);
|
||||
assert!(got.iter().zip(lab).all(|(g, w)| (g - w).abs() < 1e-3), "{depth:?}: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Anything the stored values can't carry exactly keeps the RGB compositor.
|
||||
#[test]
|
||||
fn lab_stacks_with_other_layers_keep_the_rgb_compositor() {
|
||||
let lab = [0.6, 0.5, 0.5];
|
||||
assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![Adjustment::Invert])).is_some_and(|s| s.adjusts()));
|
||||
let hue = Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() };
|
||||
assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![hue])).is_none());
|
||||
let mut multiply = lab_stack(lab, SampleType::U8, vec![Adjustment::Invert]);
|
||||
if let Some(l) = multiply.layers.last_mut() {
|
||||
l.blend = photocraft_color::blend::BlendMode::Multiply;
|
||||
}
|
||||
assert!(native_lab::LabStack::new(&multiply).is_none());
|
||||
let rgb = Document::with_background("rgb", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::rgb(0.5, 0.5, 0.5));
|
||||
assert!(native_lab::LabStack::new(&rgb).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_documents_brightness_contrast_moves_lightness_only() {
|
||||
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
|
||||
|
|
@ -2594,6 +2968,75 @@ fn lab_documents_gradient_map_follows_lightness() {
|
|||
assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]);
|
||||
}
|
||||
|
||||
// ---------- Effect-map identity is computed once per layer per render (#4437) ----------
|
||||
|
||||
/// Effect layers of every kind the cache key walks: a plain layer, a styled group with a styled
|
||||
/// child, and a layer whose mask hides its effects (rendered from a temporary copy).
|
||||
fn identity_doc(depth: SampleType) -> Document {
|
||||
let mut d = Document::with_background("t", Size::new(96, 96), ColorMode::Rgb, depth, Color::WHITE);
|
||||
let fmt = d.pixel_format();
|
||||
let mut sq = Layer::raster("sq", fmt);
|
||||
sq.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 40, 40), &[1.0, 0.0, 0.0, 1.0]);
|
||||
sq.effects.items = vec![stroke(3.0, StrokePosition::Outside), Effect::DropShadow(shadow(4.0, 120.0))];
|
||||
d.layers.push(sq);
|
||||
let mut child = Layer::raster("child", fmt);
|
||||
child.surface_mut().unwrap().fill_rect(Rect::new(50, 10, 86, 46), &[0.0, 0.6, 0.2, 1.0]);
|
||||
child.effects.items = vec![stroke(2.0, StrokePosition::Outside)];
|
||||
let mut group = Layer::group("g", vec![child]);
|
||||
group.effects.items = vec![Effect::DropShadow(shadow(5.0, 60.0))];
|
||||
d.layers.push(group);
|
||||
let mut hidden = Layer::raster("hidden", fmt);
|
||||
hidden.surface_mut().unwrap().fill_rect(Rect::new(10, 54, 46, 90), &[0.1, 0.2, 0.9, 1.0]);
|
||||
hidden.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
|
||||
hidden.mask = Some(LayerMask::reveal_all());
|
||||
hidden.advanced.layer_mask_hides_effects = true;
|
||||
d.layers.push(hidden);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memoised_effect_identity_leaves_every_render_unchanged() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let d = identity_doc(depth);
|
||||
let b = d.bounds();
|
||||
// One tile for the whole canvas never goes through `prepare_effects`; small tiles do.
|
||||
purge_effect_cache();
|
||||
let whole = render_tiled(&d, b, 4096);
|
||||
purge_effect_cache();
|
||||
let tiled = render_tiled(&d, b, 16);
|
||||
let warm = render_tiled(&d, b, 24);
|
||||
assert!(whole.px == tiled.px, "{depth:?}: tiled differs from one tile");
|
||||
assert!(whole.px == warm.px, "{depth:?}: warm differs from cold");
|
||||
assert!(whole.px.iter().any(|p| *p != [1.0; 4]), "{depth:?}: the effects should draw");
|
||||
// Edits still invalidate: a changed layer must not keep its old maps.
|
||||
let mut e = d.clone();
|
||||
e.layers[1].effects.items = vec![Effect::DropShadow(shadow(9.0, 200.0))];
|
||||
let edited = render_tiled(&e, b, 16);
|
||||
purge_effect_cache();
|
||||
assert!(edited.px == render_tiled(&e, b, 4096).px, "{depth:?}: stale maps after an edit");
|
||||
assert!(edited.px != whole.px, "{depth:?}: the edit should show");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_identity_is_built_once_per_layer_not_per_tile() {
|
||||
let mut d = identity_doc(SampleType::U8);
|
||||
// Leave out the temporary-copy layer: it is rebuilt per call by design.
|
||||
d.layers.pop();
|
||||
let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(&d, &patterns);
|
||||
let b = d.bounds();
|
||||
let tiles = 6 * 6;
|
||||
let first = render_tiled_with(&d, b, 16, &cx);
|
||||
let built = cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed);
|
||||
// sq, the group and its child: three effect layers, however many tiles.
|
||||
assert_eq!(built, 3, "identity built {built} times over {tiles} tiles");
|
||||
// A second render with the same context finds all of them memoised.
|
||||
let second = render_tiled_with(&d, b, 16, &cx);
|
||||
assert_eq!(cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed), 3);
|
||||
assert!(first.px == second.px);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_refuses_rects_beyond_the_pixel_cap() {
|
||||
let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
|
||||
|
|
@ -2668,3 +3111,106 @@ fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_eff
|
|||
assert!(!b_fx.is_empty());
|
||||
assert!(b_fx.contains_rect(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing: cargo test --release -p photocraft-compose modern_lut_timing -- --ignored --nocapture"]
|
||||
fn modern_lut_timing() {
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..100 {
|
||||
std::hint::black_box(adjust::modern_lut(-50.0, 30.0));
|
||||
}
|
||||
println!("modern_lut(-50, 30): {:?} each", t.elapsed() / 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing: cargo test --release -p photocraft-compose cmyk_ink_render_timing -- --ignored --nocapture"]
|
||||
fn cmyk_ink_render_timing() {
|
||||
// 24 MP CMYK8 photo-sized noise with one adjustment layer: the ink path (Invert) against the
|
||||
// RGB compositor (Exposure, which the inks don't carry).
|
||||
let (w, h) = (6000, 4000);
|
||||
let mut d = Document::new("c", Size::new(w, h), ColorMode::Cmyk, SampleType::U8);
|
||||
let mut l = Layer::raster("bg", d.pixel_format());
|
||||
let data: Vec<f32> = (0..(w * h) as usize)
|
||||
.flat_map(|i| [(i % 251) as f32 / 255.0, (i % 241) as f32 / 255.0, (i % 239) as f32 / 255.0, (i % 97) as f32 / 255.0, 1.0])
|
||||
.collect();
|
||||
l.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &data);
|
||||
d.layers.push(l);
|
||||
for adj in [Adjustment::Invert, Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }] {
|
||||
d.layers.truncate(1);
|
||||
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone())));
|
||||
let _ = render(&d, Rect::new(0, 0, 512, 512));
|
||||
let t = std::time::Instant::now();
|
||||
let b = render(&d, d.bounds());
|
||||
println!("{}: {:?} ({} px)", adj.label(), t.elapsed(), b.px.len());
|
||||
}
|
||||
}
|
||||
|
||||
// A pass-through group with an adjustment layer reaches the layers beneath, unless the group is
|
||||
// the base of a clipping group: then Photoshop renders it isolated, and an adjustment with no
|
||||
// content of its own in the group changes nothing (psd-tools passthrough_clipping_mask_adjustment).
|
||||
#[test]
|
||||
fn a_pass_through_clipping_base_keeps_its_adjustments_inside() {
|
||||
let base = || {
|
||||
let mut d = doc_white(4, 4);
|
||||
let group = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]);
|
||||
d.layers.push(group);
|
||||
d
|
||||
};
|
||||
// Unclipped: the invert reaches the white background.
|
||||
assert!(close4(px(&base(), 1, 1), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&base(), 1, 1));
|
||||
// With a layer clipped to the group: the background stays white.
|
||||
let mut d = base();
|
||||
let mut clip = Layer::raster("clipped", PixelFormat::RGBA8);
|
||||
clip.clipped = true;
|
||||
d.layers.push(clip);
|
||||
assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(&d, 1, 1));
|
||||
}
|
||||
|
||||
// A cache entry reserves the addresses of the tiles its key hashes, not their pixels (#4530): once
|
||||
// the document lets go of a styled layer or group (an edit replaced its tiles, or it was
|
||||
// deleted), the effect cache keeps none of its pixel memory alive, and the maps are still
|
||||
// reused while the layer is unchanged.
|
||||
#[test]
|
||||
fn effect_cache_keeps_no_pixels_of_a_dropped_layer() {
|
||||
let mut d = doc_white(300, 300);
|
||||
let mut l = solid_layer("styled", Rect::new(20, 20, 280, 280), [0.9, 0.2, 0.2, 1.0]);
|
||||
l.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
|
||||
d.layers.push(l);
|
||||
let mut g = Layer::group("g", vec![solid_layer("child", Rect::new(40, 40, 120, 120), [0.1, 0.2, 0.9, 1.0])]);
|
||||
g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })];
|
||||
d.layers.push(g);
|
||||
let first = render(&d, d.bounds());
|
||||
let tile = |l: &Layer| l.surface().and_then(|s| s.tiles().next()).map(|(_, t)| t.clone()).unwrap();
|
||||
let own = tile(&d.layers[1]);
|
||||
let LayerContent::Group(grp) = &d.layers[2].content else { panic!("not a group") };
|
||||
let child = tile(&grp.children[0]);
|
||||
assert_eq!(render(&d, d.bounds()).px, first.px, "warm render");
|
||||
drop(d);
|
||||
// Only this test's handles are left: the cache pins neither the layer's nor the child's pixels.
|
||||
assert_eq!(std::sync::Arc::strong_count(&own), 1);
|
||||
assert_eq!(std::sync::Arc::strong_count(&child), 1);
|
||||
}
|
||||
|
||||
// Concurrent callers share one memoized effect identity.
|
||||
#[test]
|
||||
fn concurrent_effect_identity_misses_compute_once() {
|
||||
let mut doc = identity_doc(SampleType::U8);
|
||||
// Make the existing pure identity hash long enough to expose overlapping misses.
|
||||
doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512];
|
||||
let layer = &doc.layers[1];
|
||||
let keys = LayerKeys::default();
|
||||
let start = std::sync::Barrier::new(8);
|
||||
std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
start.wait();
|
||||
keys.identity(layer, Some(&doc))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||
assert!(ids.iter().all(|id| *id == ids[0]));
|
||||
});
|
||||
assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,11 +45,17 @@ pub struct Action {
|
|||
/// filled by Load Actions (`actions.load`) and written by Save Actions (`actions.save`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub set: Option<String>,
|
||||
/// Button Mode colour. Empty is none; otherwise one of [`BUTTON_COLORS`].
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// Button Mode colours, in Photoshop's order. Empty is no colour.
|
||||
pub const BUTTON_COLORS: [&str; 8] = ["", "red", "orange", "yellow", "green", "blue", "violet", "gray"];
|
||||
|
||||
impl Action {
|
||||
pub fn new(name: impl Into<String>, steps: Vec<(String, Value)>) -> Self {
|
||||
Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None }
|
||||
Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None, color: String::new() }
|
||||
}
|
||||
|
||||
/// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved.
|
||||
|
|
@ -235,7 +241,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let idx = resolve(&s.actions.list, p, "actions.get")?;
|
||||
let action = &s.actions.list[idx];
|
||||
let steps: Vec<Value> = action.steps.iter().map(|(id, params)| json!([id, params])).collect();
|
||||
Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set}))
|
||||
Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set, "color": action.color}))
|
||||
}
|
||||
|
||||
fn record(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -553,6 +559,25 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"action": name, "index": idx}))
|
||||
}
|
||||
|
||||
fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "actions.setColor";
|
||||
not_playing(s, cmd)?;
|
||||
let idx = resolve(&s.actions.list, p, cmd)?;
|
||||
let Some(color) = p.get("color").and_then(Value::as_str) else {
|
||||
return Err(bad(cmd, "needs \"color\""));
|
||||
};
|
||||
if !BUTTON_COLORS.contains(&color) {
|
||||
return Err(bad(cmd, "color must be empty, red, orange, yellow, green, blue, violet, or gray"));
|
||||
}
|
||||
let Some(action) = s.actions.list.get_mut(idx) else {
|
||||
return Err(bad(cmd, "no such action"));
|
||||
};
|
||||
action.color = color.to_string();
|
||||
let name = action.name.clone();
|
||||
s.actions.touch();
|
||||
Ok(json!({"action": name, "color": color}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
|
|
@ -580,7 +605,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "Get Action",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions)"##,
|
||||
params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null, color:str} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions; color is the Button Mode swatch, or empty)"##,
|
||||
enabled: always,
|
||||
run: get,
|
||||
journal: false,
|
||||
|
|
@ -645,6 +670,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
run: rename,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.setColor",
|
||||
label: "Set Action Color",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"action":name|index, "color":""|"red"|"orange"|"yellow"|"green"|"blue"|"violet"|"gray"} → {action, color}. The Button Mode colour. Empty clears it."##,
|
||||
enabled: always,
|
||||
run: set_color,
|
||||
journal: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -957,6 +992,109 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_layer_selection_replays_by_name_on_another_document() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
|
||||
let original = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.setProps", json!({"name": "Photo"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap();
|
||||
s.execute("actions.record", json!({"name": "Duplicate photo"})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": original.0, "mode": "replace"})).unwrap();
|
||||
// The target name must be captured now, not when recording stops.
|
||||
s.execute("layer.setProps", json!({"name": "Retouched"})).unwrap();
|
||||
s.execute("layer.duplicate", json!({})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo", "mode":"replace"}));
|
||||
// Persisted actions must work in an entirely different session too.
|
||||
let saved = serde_json::to_string(&s.actions.list).unwrap();
|
||||
let mut replay = Session::new();
|
||||
replay.actions.list = serde_json::from_str(&saved).unwrap();
|
||||
replay.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
|
||||
replay.execute("layer.setProps", json!({"name": "Photo"})).unwrap();
|
||||
let target = replay.active().unwrap().active_layer.unwrap();
|
||||
assert_ne!(original, target);
|
||||
replay.execute("layer.new.layer", json!({"name": "LogoBase"})).unwrap();
|
||||
let result = replay.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
assert!(result.get("failed").is_none(), "{result}");
|
||||
assert_eq!(result["ran"], 3);
|
||||
assert_eq!(replay.active().unwrap().doc.layer(target).unwrap().name, "Retouched");
|
||||
let active = replay.active().unwrap().active_layer.unwrap();
|
||||
assert_eq!(replay.active().unwrap().doc.layer(active).unwrap().name, "Retouched copy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_selection_preserves_modes_and_duplicate_occurrences() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
|
||||
s.execute("layer.setProps", json!({"name":"Photo"})).unwrap();
|
||||
let first = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.new.group", json!({"name":"Group"})).unwrap();
|
||||
// A duplicate nested in a group must be found even when that group is collapsed.
|
||||
let second = s
|
||||
.edit("child", |doc, active| {
|
||||
let child = photocraft_doc::Layer::raster("Photo", doc.pixel_format());
|
||||
let id = child.id;
|
||||
if let photocraft_doc::LayerContent::Group(group) = &mut doc.layer_mut(active.unwrap()).unwrap().content {
|
||||
group.children.push(child);
|
||||
group.expanded = false;
|
||||
}
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("actions.record", json!({"name":"Select duplicates"})).unwrap();
|
||||
s.execute("layer.select", json!({"layer":first.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer":second.0,"mode":"add"})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(s.actions.list[0].steps[0].1, json!({"name":"Photo","occurrence":0}));
|
||||
assert_eq!(s.actions.list[0].steps[1].1, json!({"name":"Photo","occurrence":1,"mode":"add"}));
|
||||
s.execute("layer.select", json!({"layer":first.0})).unwrap();
|
||||
let r = s.execute("actions.play", json!({"action":0})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
assert_eq!(s.active().unwrap().selected_layers(), vec![first, second]);
|
||||
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"toggle"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
|
||||
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"range"})).unwrap();
|
||||
assert!(s.active().unwrap().selected_layers().contains(&second));
|
||||
// Main's imported .atn steps use a string-valued `layer` and choose the topmost match.
|
||||
s.execute("layer.select", json!({"layer":"Photo"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().selected_layers(), vec![second]);
|
||||
s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"add"})).unwrap();
|
||||
s.execute("layer.select", json!({"name":"Photo","occurrence":1,"mode":"remove"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
|
||||
// Removing an unselected layer does not add it; removing the last target keeps it.
|
||||
s.execute("layer.select", json!({"layer":"Photo","mode":"remove"})).unwrap();
|
||||
s.execute("layer.select", json!({"name":"Photo","occurrence":0,"mode":"remove"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().selected_layers(), vec![first]);
|
||||
for p in [
|
||||
json!({"name":"absent"}),
|
||||
json!({"name":"Photo"}),
|
||||
json!({"name":1}),
|
||||
json!({"name":"Photo","occurrence":-1}),
|
||||
json!({"name":"Photo","occurrence":1.5}),
|
||||
json!({"name":"Photo","occurrence":99}),
|
||||
json!({"name":"Photo","layer":first.0}),
|
||||
json!({"layer":first.0,"occurrence":0}),
|
||||
] {
|
||||
let before = s.active().unwrap().selected_layers();
|
||||
assert!(s.execute("layer.select", p.clone()).is_err(), "{p}");
|
||||
assert_eq!(s.active().unwrap().selected_layers(), before);
|
||||
}
|
||||
s.actions.list.push(Action {
|
||||
name: "Missing".into(),
|
||||
steps: vec![("layer.select".into(), json!({"name":"absent"})), ("layer.duplicate".into(), json!({}))],
|
||||
disabled: Default::default(),
|
||||
..Default::default()
|
||||
});
|
||||
let before = s.active().unwrap().doc.walk().len();
|
||||
let result = s.execute("actions.play", json!({"action":"Missing"})).unwrap();
|
||||
assert_eq!(result["ran"], 0);
|
||||
assert!(result["failed"]["error"].as_str().unwrap().contains("absent"));
|
||||
assert_eq!(s.active().unwrap().doc.walk().len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_stops_at_the_failing_step_and_reports_it() {
|
||||
let mut s = Session::new();
|
||||
|
|
@ -1308,4 +1446,24 @@ mod tests {
|
|||
let first = &s.documents()[0].doc;
|
||||
assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_color_is_one_of_the_photoshop_swatches() {
|
||||
let mut s = Session::new();
|
||||
s.execute("actions.record", json!({"name": "Print"})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let set = s.execute("actions.setColor", json!({"action": "Print", "color": "blue"})).unwrap();
|
||||
assert_eq!(set["color"], "blue");
|
||||
assert_eq!(s.actions.list[0].color, "blue");
|
||||
assert_eq!(s.execute("actions.get", json!({"action": 0})).unwrap()["color"], "blue");
|
||||
let saved = serde_json::to_string(&s.actions.list).unwrap();
|
||||
assert!(saved.contains("blue"));
|
||||
s.execute("actions.setColor", json!({"action": 0, "color": ""})).unwrap();
|
||||
assert!(s.actions.list[0].color.is_empty());
|
||||
assert!(!serde_json::to_string(&s.actions.list).unwrap().contains("color"), "an empty colour is omitted");
|
||||
for bad in [json!({"action": 0, "color": "pink"}), json!({"action": 0}), json!({"action": 4, "color": "red"}), json!({"color": "red"})] {
|
||||
assert!(s.execute("actions.setColor", bad).is_err());
|
||||
}
|
||||
assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -527,6 +527,106 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() {
|
|||
assert_eq!(rgba(&s, 23, 3), pixels);
|
||||
}
|
||||
|
||||
/// #5335: Curves (⌘M) edits the targeted layer mask, saved channel or Quick Mask at every depth.
|
||||
#[test]
|
||||
fn curves_apply_to_non_colour_targets_at_every_depth() {
|
||||
let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32);
|
||||
for depth in DEPTHS {
|
||||
for target in ["mask", "alpha", "quickMask"] {
|
||||
let mut s = session(depth, "rgb");
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
|
||||
let p = match target {
|
||||
"mask" => {
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "mask"})
|
||||
}
|
||||
"alpha" => {
|
||||
s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap();
|
||||
json!({"points": [[0, 0], [128, 200], [255, 255]], "target": {"channel": 0}})
|
||||
}
|
||||
_ => {
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "quickMask"})
|
||||
}
|
||||
};
|
||||
let edit_target = p.get("target").cloned().unwrap_or(json!(null));
|
||||
s.edit("levels", |doc, active| {
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": edit_target })).unwrap();
|
||||
surf.fill_rect(area, &[0.5]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let read = |s: &Session| {
|
||||
let d = s.active().unwrap();
|
||||
let layer = d.doc.layer(d.active_layer.unwrap()).unwrap();
|
||||
let t = match target {
|
||||
"quickMask" => d.doc.quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0),
|
||||
"alpha" => d.doc.channels[0].surface.sample_channel(8, 8, 0),
|
||||
_ => layer.mask.as_ref().unwrap().surface.sample_channel(8, 8, 0),
|
||||
};
|
||||
(rgba(s, 8, 8), t)
|
||||
};
|
||||
let ((pixels, before), past) = (read(&s), s.active().unwrap().history.past_len());
|
||||
s.execute("image.adjustments.curves", p).unwrap();
|
||||
let (pixels_after, after) = read(&s);
|
||||
let case = format!("{target} at {depth}");
|
||||
assert_eq!(pixels_after, pixels, "{case}: colour pixels stay put");
|
||||
assert!(after > before + 0.1, "{case}: the curve lifted the midtone ({before} → {after})");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}");
|
||||
s.undo();
|
||||
assert!((read(&s).1 - before).abs() < 1e-5, "{case}: undo restores the target");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #5335: a layer mask, saved channel or Quick Mask holds gray levels whatever the document's
|
||||
/// colour mode: inverting 25% grey gives 75% grey in Lab and CMYK, not an untouched plane (Lab
|
||||
/// ignores the composite) or an ink-separated result (CMYK curves the inks).
|
||||
#[test]
|
||||
fn curves_on_grey_targets_ignore_the_document_colour_mode() {
|
||||
let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32);
|
||||
for mode in ["lab", "cmyk"] {
|
||||
for depth in [8u64, 16] {
|
||||
for target in ["mask", "alpha", "quickMask"] {
|
||||
let mut s = session(depth, mode);
|
||||
let p = match target {
|
||||
"mask" => {
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
json!("mask")
|
||||
}
|
||||
"alpha" => {
|
||||
s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap();
|
||||
json!({"channel": 0})
|
||||
}
|
||||
_ => {
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
json!("quickMask")
|
||||
}
|
||||
};
|
||||
s.edit("levels", |doc, active| {
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": p })).unwrap();
|
||||
surf.fill_rect(area, &[0.25]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let read = |s: &Session| match target {
|
||||
"quickMask" => doc(s).quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0),
|
||||
"alpha" => doc(s).channels[0].surface.sample_channel(8, 8, 0),
|
||||
_ => mask_at(s, 8, 8),
|
||||
};
|
||||
let before = read(&s);
|
||||
let pixels = rgba(&s, 8, 8);
|
||||
s.execute("image.adjustments.curves", json!({"points": [[0, 255], [255, 0]], "target": p})).unwrap();
|
||||
let after = read(&s);
|
||||
let case = format!("{mode} {depth}-bit {target}");
|
||||
assert!((before - 0.25).abs() < 0.01, "{case}: filled with 25% grey ({before})");
|
||||
assert!((after - 0.75).abs() < 0.02, "{case}: inverting 25% grey gives 75% grey ({after})");
|
||||
assert_eq!(rgba(&s, 8, 8), pixels, "{case}: colour pixels stay put");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() {
|
||||
// The same cream on the left (x < 8) and the right (x ≥ 40), grey between.
|
||||
|
|
@ -561,3 +661,53 @@ fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() {
|
|||
let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err();
|
||||
assert!(err.to_string().contains("bad point"), "{err}");
|
||||
}
|
||||
|
||||
/// #6095: ⌘I in Quick Mask inverts the whole mask, not only where it was white, so leaving Quick
|
||||
/// Mask selects everything but the original rectangle, whichever way the colour reads. With
|
||||
/// Color Indicates Selected Areas and nothing selected, it selects everything. A black alpha
|
||||
/// channel inverts to white.
|
||||
#[test]
|
||||
fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth, "rgb");
|
||||
let pixels = rgba(&s, 2, 2);
|
||||
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap();
|
||||
let q = &doc(&s).quick_mask.as_ref().unwrap().surface;
|
||||
let (inside, outside) = (q.sample_channel(15, 12, 0), q.sample_channel(2, 2, 0));
|
||||
assert!(inside < 0.01 && outside > 0.99, "{depth}-bit Quick Mask inverted: inside {inside}, outside {outside}");
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap();
|
||||
let sel = doc(&s).selection.as_ref().expect("an inverse selection after Quick Mask");
|
||||
let (inside, outside) = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0));
|
||||
assert!(inside < 0.01 && outside > 0.99, "{depth}-bit selection: inside {inside}, outside {outside}");
|
||||
assert_eq!(rgba(&s, 2, 2), pixels, "{depth}-bit: the layer is untouched");
|
||||
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("channel.new", json!({"name": "Black", "fill": "black"})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({"target": {"channel": 0}})).unwrap();
|
||||
let a = doc(&s).channels[0].surface.sample_channel(40, 30, 0);
|
||||
assert!(a > 0.99, "{depth}-bit black alpha channel inverts to white ({a})");
|
||||
s.undo();
|
||||
assert!(doc(&s).channels[0].surface.sample_channel(40, 30, 0) < 0.01, "{depth}-bit: undo restores the black");
|
||||
|
||||
// Color Indicates Selected Areas: a rectangle comes back inverted, and nothing selected
|
||||
// comes back as everything selected.
|
||||
for (start, inside, outside) in [("rectangle", 0.0, 1.0), ("nothing", 1.0, 1.0)] {
|
||||
let mut s = session(depth, "rgb");
|
||||
s.execute("channel.options", json!({"channel": "quickMask", "indicates": "selected"})).unwrap();
|
||||
if start == "rectangle" {
|
||||
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap();
|
||||
}
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap();
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap();
|
||||
let sel = doc(&s).selection.as_ref().unwrap_or_else(|| panic!("{depth}-bit Selected Areas, {start}: a selection after Quick Mask"));
|
||||
let got = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0));
|
||||
assert!(
|
||||
(got.0 - inside).abs() < 0.01 && (got.1 - outside).abs() < 0.01,
|
||||
"{depth}-bit Selected Areas, {start}: selection (inside, outside) {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,12 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> {
|
|||
pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target());
|
||||
// An inner command may select a layer that disappears when the compound rolls back.
|
||||
// Selection, channel targeting and coalescing must roll back with the document (#4975).
|
||||
let anchor = st.layer_anchor;
|
||||
let channel_view = st.channel_view.clone();
|
||||
let coalesce = st.coalesce.clone();
|
||||
let was_clean = !st.is_dirty();
|
||||
let journal = s.journal.len();
|
||||
// Color Sampler points are view state a step may move (crop, rotation): roll them back too, so
|
||||
// a failed second step can't leave them pinned to pixels the restored document no longer has.
|
||||
|
|
@ -187,23 +193,37 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
|
|||
}
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
st.history = history;
|
||||
match r {
|
||||
let result = match r {
|
||||
Ok(v) => {
|
||||
// As in `Session::edit`: undo targets what was selected just before the step (#1356).
|
||||
st.history.set_current_layers(prior);
|
||||
st.history.record(label, before, st.layer_target());
|
||||
st.history.trim(&st.doc);
|
||||
st.trim_history();
|
||||
st.coalesce = None;
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
st.doc = before;
|
||||
st.active_layer = prior.active;
|
||||
st.selected_layers = prior.selected;
|
||||
st.layer_anchor = anchor;
|
||||
st.channel_view = channel_view;
|
||||
st.coalesce = coalesce;
|
||||
st.color_samplers = samplers;
|
||||
st.revision += 1;
|
||||
// A failed compound does not make an otherwise untouched document dirty.
|
||||
if was_clean {
|
||||
st.saved_revision = st.revision;
|
||||
}
|
||||
st.last_damage = None;
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
if result.is_err() {
|
||||
// The app-wide tool colours/mask mode follow the restored channel target.
|
||||
s.sync_tools_mask_target();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Current time as ISO 8601 UTC (`2026-10-01T12:34:56Z`); empty on the web (no clock).
|
||||
|
|
|
|||
|
|
@ -11,6 +11,69 @@ fn d(s: &Session) -> Arc<Document> {
|
|||
s.active().unwrap().doc.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_compound_restores_layer_target_history_and_journal() {
|
||||
let mut s = session(80, 60, 8);
|
||||
let background = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Selected"})).unwrap();
|
||||
let selected = s.active().unwrap().active_layer.unwrap();
|
||||
{
|
||||
let st = s.active_mut().unwrap();
|
||||
st.selected_layers = vec![background, selected];
|
||||
st.layer_anchor = Some(background);
|
||||
st.coalesce = Some("previous-gesture".into());
|
||||
}
|
||||
|
||||
let st = s.active().unwrap();
|
||||
let prior_doc = st.doc.clone();
|
||||
let prior_history = st.history.past_len();
|
||||
let prior_channel_view = st.channel_view.clone();
|
||||
let prior_mask_targeted = s.tools.mask_targeted;
|
||||
let prior_journal = s.journal.len();
|
||||
let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> {
|
||||
// Mirrors a scale-bar creation followed by a failed type.create: the
|
||||
// inner command selects a new layer, which rollback must remove.
|
||||
s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?;
|
||||
s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask;
|
||||
s.sync_tools_mask_target();
|
||||
Err(bad("test", "synthetic second step failed"))
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("synthetic second step failed"));
|
||||
let st = s.active().unwrap();
|
||||
assert!(Arc::ptr_eq(&prior_doc, &st.doc), "the original document is restored");
|
||||
assert_eq!(st.history.past_len(), prior_history, "no undo step remains");
|
||||
assert_eq!(s.journal.len(), prior_journal, "no rolled-back command is journaled");
|
||||
assert_eq!(st.active_layer, Some(selected));
|
||||
assert_eq!(st.selected_layers, vec![background, selected]);
|
||||
assert_eq!(st.layer_anchor, Some(background));
|
||||
assert_eq!(st.channel_view, prior_channel_view);
|
||||
assert_eq!(s.tools.mask_targeted, prior_mask_targeted, "tool mask target follows the restored channel");
|
||||
assert_eq!(st.coalesce.as_deref(), Some("previous-gesture"));
|
||||
assert!(st.doc.layer(selected).is_some());
|
||||
|
||||
// Subsequent ordinary commands target the original layer, not the
|
||||
// nonexistent scale bar.
|
||||
s.execute("layer.setProps", json!({"layer": selected.0, "visible": false})).unwrap();
|
||||
assert!(!s.active().unwrap().doc.layer(selected).unwrap().visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_compound_preserves_a_clean_document() {
|
||||
let mut s = session(80, 60, 8);
|
||||
assert!(!s.active().unwrap().is_dirty());
|
||||
let prior_doc = d(&s);
|
||||
let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> {
|
||||
s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?;
|
||||
Err(bad("test", "synthetic second step failed"))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("synthetic second step failed"));
|
||||
assert!(Arc::ptr_eq(&prior_doc, &d(&s)));
|
||||
assert!(!s.active().unwrap().is_dirty(), "a rolled-back edit does not need saving");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_measurements_refuses_an_area_too_large_to_measure() {
|
||||
// #933: with no selection the whole document is measured, and `width * height` overflowed
|
||||
|
|
|
|||
|
|
@ -983,6 +983,26 @@ fn pattern_desc(p: &Value) -> Option<Descriptor> {
|
|||
}
|
||||
|
||||
fn make_from(d: &Descriptor) -> Mapped {
|
||||
// Duplicate layer/group into a new document: `Mk` with a document class, a layer
|
||||
// `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN`
|
||||
// (the copy's name). A `Dplc` with a `T` document reference (duplicate into another
|
||||
// open document) stays a kept Photoshop step: document indices mean nothing at replay.
|
||||
if let Some(r) = get_ref(d, "null")
|
||||
&& ref_class(r).as_deref() == Some("Dcmn")
|
||||
&& matches!(target(r), Target::Class)
|
||||
&& let Some(u) = get_ref(d, "Usng")
|
||||
&& matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection"))
|
||||
&& let Some(mut p) = layer_target(u)
|
||||
{
|
||||
p.insert("document".into(), "new".into());
|
||||
if let Some(n) = get_text(d, "Nm ") {
|
||||
p.insert("documentName".into(), n.into());
|
||||
}
|
||||
if let Some(n) = get_text(d, "LyrN") {
|
||||
p.insert("name".into(), n.into());
|
||||
}
|
||||
return step("layer.duplicate", Value::Object(p));
|
||||
}
|
||||
if let Some(nw) = get_obj(d, "Nw ") {
|
||||
// Layer via Copy / Cut (`Usng` = the selection), or a new document.
|
||||
if get_enum(d, "Usng").as_deref() == Some("Slct") {
|
||||
|
|
@ -1117,6 +1137,21 @@ fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
|
|||
let t = pattern_desc(p)?;
|
||||
desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))
|
||||
}
|
||||
"layer.duplicate" => {
|
||||
// Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`).
|
||||
// Anything else falls through to the `Dplc` event below.
|
||||
if jstr(p, "document") != Some("new") {
|
||||
return None;
|
||||
}
|
||||
let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p));
|
||||
if let Some(n) = jstr(p, "documentName") {
|
||||
d = d.with("Nm ", text(n));
|
||||
}
|
||||
if let Some(n) = jstr(p, "name") {
|
||||
d = d.with("LyrN", text(n));
|
||||
}
|
||||
d
|
||||
}
|
||||
"file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))),
|
||||
"file.new" => {
|
||||
let mut n = desc("Dcmn");
|
||||
|
|
@ -1876,6 +1911,11 @@ fn duplicate_from(d: &Descriptor) -> Mapped {
|
|||
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
|
||||
match ref_class(r).as_deref() {
|
||||
Some("Lyr") => {
|
||||
// A `T` document reference (duplicate into another open document) has no
|
||||
// replayable meaning here: keep the step verbatim rather than misplay it.
|
||||
if get_ref(d, "T ").is_some() {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } };
|
||||
if let Some(n) = get_text(d, "Nm ") {
|
||||
p.insert("name".into(), n.into());
|
||||
|
|
@ -1897,6 +1937,10 @@ fn duplicate_from(d: &Descriptor) -> Mapped {
|
|||
}
|
||||
|
||||
fn duplicate_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
// Duplicate into a new document is a `Mk` (handled above); let it fall through.
|
||||
if id == "layer.duplicate" && jstr(p, "document") == Some("new") {
|
||||
return None;
|
||||
}
|
||||
let mut d = match id {
|
||||
"layer.duplicate" => desc("null").with("null", layer_ref(p)),
|
||||
"image.duplicate" => {
|
||||
|
|
@ -2696,6 +2740,7 @@ const EVENTS: &[Event] = &[
|
|||
"layer.new.layer",
|
||||
"layer.new.group",
|
||||
"layer.groupLayers",
|
||||
"layer.duplicate",
|
||||
"layer.new.layerViaCopy",
|
||||
"layer.new.layerViaCut",
|
||||
"layer.newFillLayer.solidColor",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,12 @@ fn every_mapped_command_exports_and_imports_back() {
|
|||
("image.mode.grayscale", json!({})),
|
||||
("image.mode.bits16", json!({})),
|
||||
("layer.duplicate", json!({"name": "copy"})),
|
||||
// Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of
|
||||
// a recorded Duplicate Layer with Destination: New, plus a named layer).
|
||||
("layer.duplicate", json!({"document": "new"})),
|
||||
("layer.duplicate", json!({"document": "new", "documentName": "test new name"})),
|
||||
("layer.duplicate", json!({"document": "new", "name": "Laer name change"})),
|
||||
("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})),
|
||||
("image.duplicate", json!({"mergedOnly": true})),
|
||||
("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})),
|
||||
("layer.new.group", json!({"name": "G"})),
|
||||
|
|
@ -448,6 +454,17 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
|
|||
Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))),
|
||||
),
|
||||
item("GsnB", "Gaussian Blur", None),
|
||||
// Duplicate into another open document: the `T` reference means nothing at
|
||||
// replay, so the step is kept verbatim instead of misplayed as a same-doc copy.
|
||||
item(
|
||||
"Dplc",
|
||||
"Duplicate",
|
||||
Some(
|
||||
desc("null")
|
||||
.with("null", target_ref("Lyr "))
|
||||
.with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }])),
|
||||
),
|
||||
),
|
||||
];
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Odd"),
|
||||
|
|
@ -457,8 +474,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
|
|||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]);
|
||||
assert_eq!(imp.unsupported.len(), 4);
|
||||
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]);
|
||||
assert_eq!(imp.unsupported.len(), 5);
|
||||
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,22 @@ fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
|||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// Photoshop ExtendScript / UXP script suffixes — not PhotoCraft JSON or command lists.
|
||||
fn extendscript_ext(path: &str) -> Option<&str> {
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
let dot = name.rfind('.')?;
|
||||
let ext = &name[dot + 1..];
|
||||
let lower = ext.to_ascii_lowercase();
|
||||
matches!(lower.as_str(), "js" | "jsx" | "jsxbin" | "psjs" | "ccjs").then_some(ext)
|
||||
}
|
||||
|
||||
fn reject_extendscript(cmd: &str, path: &str) -> Result<()> {
|
||||
match extendscript_ext(path) {
|
||||
Some(_) => Err(bad(cmd, "PhotoCraft scripts are JSON or command lists (one command.id {json} per line), not Photoshop ExtendScript (.js/.jsx)")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Session state of the File-menu commands (not saved, not history).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FileMenuState {
|
||||
|
|
@ -127,6 +143,12 @@ fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec<Value> {
|
|||
/// File › Scripts › Browse…: run a script file on the session.
|
||||
fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.scripts.browse";
|
||||
if let Some(path) = p.get("path").and_then(Value::as_str) {
|
||||
reject_extendscript(cmd, path)?;
|
||||
}
|
||||
if let Some(name) = p.get("name").and_then(Value::as_str) {
|
||||
reject_extendscript(cmd, name)?;
|
||||
}
|
||||
let steps = match (p.get("path").and_then(Value::as_str), p.get("script").and_then(Value::as_str), p.get("steps")) {
|
||||
(Some(path), _, _) => parse_script(&String::from_utf8_lossy(&read_file(path)?))?,
|
||||
(_, Some(text), _) => parse_script(text)?,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,27 @@ fn browse_runs_a_script_file() {
|
|||
assert_eq!(r["results"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_rejects_photoshop_extendscript() {
|
||||
let dir = tmp("jsx");
|
||||
let jsx = format!("{dir}/hello.jsx");
|
||||
std::fs::write(&jsx, "alert('hi');\n").unwrap();
|
||||
let mut s = Session::new();
|
||||
let err = s.execute("file.scripts.browse", json!({"path": jsx})).unwrap_err().to_string();
|
||||
assert!(err.contains("not Photoshop ExtendScript"), "{err}");
|
||||
assert!(err.contains("JSON"), "{err}");
|
||||
let js = format!("{dir}/hello.js");
|
||||
std::fs::write(&js, "alert('hi');\n").unwrap();
|
||||
assert!(s.execute("file.scripts.browse", json!({"path": js})).is_err());
|
||||
let err = s.execute("file.scripts.browse", json!({"name": "Resize.jsx", "script": "alert(1);"})).unwrap_err().to_string();
|
||||
assert!(err.contains(".js/.jsx"), "{err}");
|
||||
// A JSON script still runs even if the file name is not a Photoshop suffix.
|
||||
let json = format!("{dir}/rotate.json");
|
||||
std::fs::write(&json, r#"[["file.new", {"width": 8, "height": 8}]]"#).unwrap();
|
||||
let r = s.execute("file.scripts.browse", json!({"path": json})).unwrap();
|
||||
assert_eq!(r["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_browse_script_captures_each_effect_once() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -143,6 +143,11 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result<Bru
|
|||
if let Some(p) = pattern {
|
||||
out.texture.pattern = p;
|
||||
}
|
||||
if patch.get("size").is_some() && !touches(&["dualBrush", "size"]) {
|
||||
let new_size = out.size;
|
||||
out.size = base.size;
|
||||
out.resize_with_dual_brush(new_size);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +193,7 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result<BrushSettings>
|
|||
// A library preset's tips stay on disk until a stroke uses them (#1843).
|
||||
s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?;
|
||||
if let Some(v) = num(p, "size") {
|
||||
b.size = v.max(0.5);
|
||||
b.resize_with_dual_brush(v.max(0.5));
|
||||
}
|
||||
if let Some(v) = num(p, "hardness") {
|
||||
b.hardness = v.clamp(0.0, 1.0);
|
||||
|
|
@ -868,11 +873,24 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
v[y * w + x] = ((1.0 - l) * c[3] * sv[(y * w + x) * sc]).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let (brush, tw, th) = sampled_brush(cmd, &v, w, h)?;
|
||||
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() });
|
||||
s.tools.brush = brush;
|
||||
s.tools.current_preset = Some(name.clone());
|
||||
s.tools.brush_changed();
|
||||
Ok(json!({ "name": name, "width": tw, "height": th }))
|
||||
}
|
||||
|
||||
/// A sampled-tip brush from a paint-amount map (`w`×`h`, row-major, 0 = no paint, 1 = full):
|
||||
/// cropped to the painted extent, at most [`MAX_TIP`] px a side. Shared by Define Brush Preset
|
||||
/// and importing an image file as a brush (`brush.presets.importImage`).
|
||||
pub(crate) fn sampled_brush(cmd: &str, v: &[f32], w: usize, h: usize) -> Result<(BrushSettings, u32, u32)> {
|
||||
let at = |x: usize, y: usize| v.get(y * w + x).copied().unwrap_or(0.0);
|
||||
// Crop to the painted extent.
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if v[y * w + x] > 1e-4 {
|
||||
if at(x, y) > 1e-4 {
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x + 1);
|
||||
|
|
@ -881,23 +899,19 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
}
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Err(bad(cmd, "the selected pixels contain no paint (all white or transparent)"));
|
||||
return Err(bad(cmd, "the pixels contain no paint (all white or transparent)"));
|
||||
}
|
||||
let (tw, th) = ((x1 - x0) as u32, (y1 - y0) as u32);
|
||||
if tw.max(th) > MAX_TIP {
|
||||
return Err(bad(cmd, format!("selection too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)")));
|
||||
return Err(bad(cmd, format!("too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)")));
|
||||
}
|
||||
let mut data = Vec::with_capacity((tw * th) as usize);
|
||||
for y in y0..y1 {
|
||||
data.extend_from_slice(&v[y * w + x0..y * w + x1]);
|
||||
data.extend((x0..x1).map(|x| at(x, y)));
|
||||
}
|
||||
let tip = GrayTile::from_f32(tw, th, &data);
|
||||
let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() };
|
||||
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() });
|
||||
s.tools.brush = brush;
|
||||
s.tools.current_preset = Some(name.clone());
|
||||
s.tools.brush_changed();
|
||||
Ok(json!({ "name": name, "width": tw, "height": th }))
|
||||
Ok((brush, tw, th))
|
||||
}
|
||||
|
||||
pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -349,6 +349,38 @@ fn set_brush_merges_fields() {
|
|||
assert_eq!(s.tools.brush.texture.pattern, chalk_pattern);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_a_brush_resizes_its_enabled_secondary_tip() {
|
||||
let mut s = session(64, 64);
|
||||
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 12}})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"size": 80})).unwrap();
|
||||
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 24.0));
|
||||
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 20}})).unwrap();
|
||||
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (20.0, 6.0));
|
||||
|
||||
// Editing both diameters in the same patch is an explicit secondary-tip size.
|
||||
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"size": 18}})).unwrap();
|
||||
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 18.0));
|
||||
s.execute("tools.setBrush", json!({"dualBrush": {"size": 9}})).unwrap();
|
||||
assert_eq!(s.tools.brush.dual_brush.size, 9.0);
|
||||
|
||||
// The stroke-level size override must use the same proportional dimensions.
|
||||
let for_stroke = resolve_brush(&s, &json!({"size": 80, "points": [[10, 10]]}), "paint.stroke").unwrap();
|
||||
assert_eq!((for_stroke.size, for_stroke.dual_brush.size), (80.0, 18.0));
|
||||
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (40.0, 9.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uncaptured_preset_size_keeps_dual_brush_proportions() {
|
||||
let mut s = session(32, 32);
|
||||
s.execute("tools.setBrush", json!({"size": 40, "dualBrush": {"enabled": true, "size": 10}})).unwrap();
|
||||
s.execute("brush.presets.save", json!({"name": "Dual", "captureSize": false})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"size": 80})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"preset": "Dual"})).unwrap();
|
||||
assert_eq!((s.tools.brush.size, s.tools.brush.dual_brush.size), (80.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() {
|
||||
let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0);
|
||||
|
|
@ -910,6 +942,23 @@ fn an_offer_that_does_not_match_the_commit_is_ignored() {
|
|||
assert!(r.get("fromLive").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_brush_takes_a_per_dynamic_curve_and_a_patch_resets_it() {
|
||||
let mut s = session(20, 20);
|
||||
let curve = json!([[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]);
|
||||
s.execute("tools.setBrush", json!({"shapeDynamics": {"enabled": true, "size": {"control": "penPressure", "curve": curve}}})).unwrap();
|
||||
let with = s.tools.brush.clone();
|
||||
assert_eq!(with.shape_dynamics.size.curve.points(), vec![[0.0, 0.0], [0.5, 0.2], [1.0, 1.0]]);
|
||||
assert!(with.transfer.opacity.curve.is_linear(), "other dynamics keep their own curve");
|
||||
// The panel's diff back to linear is a real patch, and applying it restores the linear curve.
|
||||
let mut linear = with.clone();
|
||||
linear.shape_dynamics.size.curve = photocraft_paint::ResponseCurve::LINEAR;
|
||||
let patch = brush_patch(&with, &linear);
|
||||
assert_eq!(patch["shapeDynamics"]["size"]["curve"], json!([[0.0, 0.0], [1.0, 1.0]]));
|
||||
s.execute("tools.setBrush", patch).unwrap();
|
||||
assert!(s.tools.brush.shape_dynamics.size.curve.is_linear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_brush_is_remembered_when_the_revision_moves_only() {
|
||||
let mut s = session(8, 8);
|
||||
|
|
|
|||
|
|
@ -96,10 +96,13 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit:
|
|||
return Err(EngineError::Other(format!("no colour channel {k} in this layer")));
|
||||
}
|
||||
if !area.is_empty() {
|
||||
let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
|
||||
let len = (area.width() as usize).checked_mul(area.height() as usize).ok_or_else(|| EngineError::Other("the channel paste is too large".into()))?;
|
||||
let mut rgba = crate::allocation::filled(len, [0.0f32; 4], "pasting into a channel")?;
|
||||
src.read_rgba_into(area, &mut rgba);
|
||||
let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1)));
|
||||
let mut px = surf.read_region(area);
|
||||
let weight = limit
|
||||
.map(|m| crate::allocation::read_region(m, area, "reading the channel paste selection").map(|v| (v, m.format().channels().max(1))))
|
||||
.transpose()?;
|
||||
let mut px = crate::allocation::read_region(surf, area, "pasting into a channel")?;
|
||||
for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() {
|
||||
let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0));
|
||||
let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]);
|
||||
|
|
@ -107,7 +110,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit:
|
|||
*c += (v - *c) * w.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
surf.write_region(area, &px);
|
||||
surf.try_write_region(area, &px)?;
|
||||
surf.prune();
|
||||
}
|
||||
doc.selection = None;
|
||||
|
|
|
|||
|
|
@ -164,6 +164,25 @@ fn clearing_a_channel_reports_allocation_failure_without_editing() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_paste_allocation_failure_is_transactional() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session(depth);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let history = s.active().unwrap().history.entries();
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = paste(&mut s, &json!({}), 1, true, None, "Paste");
|
||||
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(*s.active().unwrap().doc, *before);
|
||||
assert_eq!(s.active().unwrap().history.entries(), history);
|
||||
paste(&mut s, &json!({}), 1, true, None, "Paste").unwrap();
|
||||
assert!(s.undo());
|
||||
assert_eq!(*s.active().unwrap().doc, *before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_copy_allocation_failure_keeps_clipboard_and_document() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
|
|
|||
|
|
@ -182,6 +182,82 @@ pub fn composite_name(mode: ColorMode) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Curves dialog's Channel popup lists only this channel when the Channels panel has a
|
||||
/// single non-composite target (a colour component, layer mask, saved channel or Quick Mask).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IsolatedCurveChannel {
|
||||
/// Channels-panel name, including a custom alpha name or "{layer} Mask".
|
||||
pub name: String,
|
||||
/// Curves param key: empty = composite `points`; otherwise `red`/`green`/…
|
||||
pub key: &'static str,
|
||||
/// Index into the tone-space histograms (composite = 0 for RGB/CMYK).
|
||||
pub hist: usize,
|
||||
/// Colour components keep their curve tint; masks and alphas do not.
|
||||
pub tint: bool,
|
||||
/// Built-in labels (colour channels, Quick Mask) follow the interface language; a custom
|
||||
/// alpha name or a "{layer} Mask" keeps the layer name, so it stays exactly as entered (#5335).
|
||||
pub translate: bool,
|
||||
/// The gray plane this target edits, whatever the document's colour mode; its surface is
|
||||
/// what the dialog's histogram must describe (#5335). None for colour components.
|
||||
pub gray: Option<GraySurface>,
|
||||
}
|
||||
|
||||
/// A grayscale plane Curves can target: the dialog parses and previews its curves in gray levels
|
||||
/// even in Lab and CMYK documents (#5335).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GraySurface {
|
||||
/// The active layer's layer mask.
|
||||
LayerMask,
|
||||
/// A saved channel, as an index into [`Document::channels`].
|
||||
Alpha(usize),
|
||||
/// The Quick Mask.
|
||||
QuickMask,
|
||||
}
|
||||
|
||||
/// Isolated Curves channel for the active document, matching [`inject_target`].
|
||||
pub fn isolated_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
|
||||
isolated_curve_channel_for(st, false)
|
||||
}
|
||||
|
||||
/// [`isolated_curve_channel`] with the Layers panel's mask target added: clicking a mask
|
||||
/// thumbnail targets it without touching the Channels panel, and both ways of selecting the mask
|
||||
/// must give the same Curves dropdown (#5335).
|
||||
pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Option<IsolatedCurveChannel> {
|
||||
let doc = &st.doc;
|
||||
let mode = edit_mode(doc);
|
||||
match st.channel_view.target {
|
||||
ChannelTarget::Color(k) => {
|
||||
let names = color_names(mode);
|
||||
let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode));
|
||||
let name = names.get(k)?.to_string();
|
||||
let key = *keys.get(k)?;
|
||||
let hist = if matches!(mode, ColorMode::Lab) { k } else { k.saturating_add(1) };
|
||||
Some(IsolatedCurveChannel { name, key, hist, tint: true, translate: true, gray: None })
|
||||
}
|
||||
ChannelTarget::Alpha(i) => doc.channels.get(i).map(|c| IsolatedCurveChannel {
|
||||
name: c.name.clone(),
|
||||
key: "",
|
||||
hist: 0,
|
||||
tint: false,
|
||||
translate: false,
|
||||
gray: Some(GraySurface::Alpha(i)),
|
||||
}),
|
||||
ChannelTarget::Composite if doc.quick_mask.is_some() => {
|
||||
Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) })
|
||||
}
|
||||
ChannelTarget::LayerMask => layer_mask_curve_channel(st),
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
|
||||
let layer = st.active_layer.and_then(|id| st.doc.layer(id))?;
|
||||
layer.mask.as_ref()?;
|
||||
let name = format!("{} Mask", layer.name);
|
||||
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) })
|
||||
}
|
||||
|
||||
fn edit_mode(doc: &Document) -> ColorMode {
|
||||
doc.pixel_format().mode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -867,3 +867,51 @@ fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() {
|
|||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (20, 10, 40, 30));
|
||||
}
|
||||
}
|
||||
|
||||
/// #5335: a single targeted channel is the only Curves popup entry, named as in the Channels panel.
|
||||
#[test]
|
||||
fn isolated_curve_channel_matches_the_channels_panel() {
|
||||
let mut s = session();
|
||||
assert!(isolated_curve_channel(s.active().unwrap()).is_none(), "composite lists every colour channel");
|
||||
s.execute("channel.target", json!({"channel": {"color": 1}})).unwrap();
|
||||
let green = isolated_curve_channel(s.active().unwrap()).expect("green");
|
||||
assert_eq!((green.name.as_str(), green.key, green.tint), ("Green", "green", true));
|
||||
assert!(green.translate && green.gray.is_none(), "built-in colour labels follow the language");
|
||||
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "mask"})).unwrap();
|
||||
let mask = isolated_curve_channel(s.active().unwrap()).expect("mask");
|
||||
assert_eq!(mask.key, "");
|
||||
assert!(!mask.tint);
|
||||
assert_eq!(mask.name, format!("{} Mask", doc(&s).layer(s.active().unwrap().active_layer.unwrap()).unwrap().name));
|
||||
|
||||
s.execute("channel.new", json!({"name": "Green", "fill": "white"})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": 0})).unwrap();
|
||||
let alpha = isolated_curve_channel(s.active().unwrap()).expect("alpha");
|
||||
assert_eq!((alpha.name.as_str(), alpha.key, alpha.tint), ("Green", "", false));
|
||||
assert!(!alpha.translate, "a custom channel name stays exactly as entered");
|
||||
assert_eq!(alpha.gray, Some(GraySurface::Alpha(0)));
|
||||
|
||||
let mut qm = session();
|
||||
qm.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
let row = isolated_curve_channel(qm.active().unwrap()).expect("quick mask");
|
||||
assert_eq!((row.name.as_str(), row.tint), ("Quick Mask", false));
|
||||
assert_eq!(row.gray, Some(GraySurface::QuickMask));
|
||||
}
|
||||
|
||||
/// #5335: selecting the mask in the Layers panel gives the same isolated Curves channel as
|
||||
/// selecting it in the Channels panel; the Quick Mask keeps priority.
|
||||
#[test]
|
||||
fn layers_panel_mask_target_isolates_the_curve_channel() {
|
||||
let mut s = session();
|
||||
assert!(isolated_curve_channel_for(s.active().unwrap(), true).is_none(), "a layer without a mask is not targeted");
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "mask"})).unwrap();
|
||||
let by_panel = isolated_curve_channel(s.active().unwrap()).expect("mask targeted from the Channels panel");
|
||||
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
|
||||
let by_layers = isolated_curve_channel_for(s.active().unwrap(), true).expect("mask targeted from the Layers panel");
|
||||
assert_eq!(by_panel, by_layers, "both ways of selecting the mask give the same dropdown");
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
let row = isolated_curve_channel_for(s.active().unwrap(), true).expect("quick mask wins");
|
||||
assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec<LayerId> {
|
|||
if group.len() > 1 { group } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// The base `id` clips to (itself when it is a base), when it is in a clip group.
|
||||
pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option<LayerId> {
|
||||
group_of(doc, id).first().copied()
|
||||
}
|
||||
|
||||
/// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself
|
||||
/// clipped (it is not a base) or nothing is clipped to it.
|
||||
fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
|
||||
|
|
@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them
|
||||
/// with it).
|
||||
pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec<LayerId> {
|
||||
let Some(path) = doc.path_of(id) else { return Vec::new() };
|
||||
let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() };
|
||||
list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect()
|
||||
}
|
||||
|
||||
/// The clip groups of layers about to move or be deleted, read before they leave their slots.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Before {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
|
|||
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session, inspect, pixels};
|
||||
|
||||
|
|
@ -143,10 +143,32 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option<String>) -> Value {
|
||||
let needs_document =
|
||||
s.active().is_none() && reason.as_deref().is_some_and(|r| ["no document", "no active document", "no open document"].iter().any(|p| r.starts_with(p)));
|
||||
json!({
|
||||
let mut entry = json!({
|
||||
"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params,
|
||||
"enabled": reason.is_none(), "disabledReason": reason, "needsDocument": needs_document,
|
||||
})
|
||||
});
|
||||
if let (Some(k), Some(o)) = (search_keywords(c.id), entry.as_object_mut()) {
|
||||
o.insert("keywords".into(), json!(k));
|
||||
}
|
||||
entry
|
||||
}
|
||||
|
||||
/// Other names a command is looked for by, beyond its id and label: the Photoshop tools and
|
||||
/// options it stands for (#5060). `select.rect` is also the Elliptical and Single Row/Column
|
||||
/// Marquee, which an agent searches for as "ellipse" or "marquee".
|
||||
pub(crate) const SEARCH_KEYWORDS: &[(&str, &str)] =
|
||||
&[("select.rect", "Rectangular Marquee, Elliptical Marquee (\"ellipse\":true), ellipse, oval, circle, Single Row/Column Marquee (\"line\")")];
|
||||
|
||||
/// [`SEARCH_KEYWORDS`] of command `id`, if it has any.
|
||||
pub fn search_keywords(id: &str) -> Option<&'static str> {
|
||||
SEARCH_KEYWORDS.iter().find(|(k, _)| *k == id).map(|(_, words)| *words)
|
||||
}
|
||||
|
||||
/// Does a [`list_entry`] row match a command-list filter (`commands --filter`, `engine.commands`,
|
||||
/// MCP `command_list`)? Its id, label or keywords contain `needle`, ignoring case.
|
||||
pub fn list_entry_matches(entry: &Value, needle: &str) -> bool {
|
||||
let needle = needle.to_lowercase();
|
||||
["id", "label", "keywords"].iter().any(|k| entry.get(*k).and_then(Value::as_str).is_some_and(|v| v.to_lowercase().contains(&needle)))
|
||||
}
|
||||
|
||||
pub fn find(id: &str) -> Option<&'static CommandSpec> {
|
||||
|
|
@ -231,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
|
|||
}
|
||||
/// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in
|
||||
/// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which
|
||||
/// becomes the base; Release releases every selected layer. One history step either way.
|
||||
/// becomes the base; Release releases every selected layer. Releasing a clipped layer also
|
||||
/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected
|
||||
/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer
|
||||
/// that is no longer clipped. One history step either way.
|
||||
fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
|
||||
let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" };
|
||||
let ids: Vec<LayerId> = if p.get("layer").is_some() {
|
||||
|
|
@ -245,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
|
|||
(false, all) => all.to_vec(),
|
||||
}
|
||||
};
|
||||
let ids = if clip {
|
||||
ids
|
||||
} else {
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let mut all = ids.clone();
|
||||
for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) {
|
||||
for above in crate::clip_group::clipped_above(doc, *id) {
|
||||
if !all.contains(&above) {
|
||||
all.push(above);
|
||||
}
|
||||
}
|
||||
}
|
||||
all
|
||||
};
|
||||
s.edit(label, |doc, _| {
|
||||
for id in &ids {
|
||||
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip;
|
||||
|
|
@ -359,12 +398,12 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
|
|||
});
|
||||
}
|
||||
if crate::channel_cmds::is_channel_target(p) {
|
||||
// Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel.
|
||||
// Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel as on
|
||||
// a layer mask, its unstored black included, so ⌘I in Quick Mask inverts it all (#6095).
|
||||
return s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? {
|
||||
pixels::adjust_surface(surf, &adj, sel.as_ref(), ColorMode::Grayscale);
|
||||
surf.prune();
|
||||
pixels::adjust_mask(surf, &adj, sel.as_ref());
|
||||
}
|
||||
Ok(Value::Null)
|
||||
});
|
||||
|
|
@ -392,6 +431,31 @@ fn doc_mode(s: &Session) -> ColorMode {
|
|||
s.active().map_or(ColorMode::Rgb, |d| d.doc.mode)
|
||||
}
|
||||
|
||||
/// The colour mode a destructive adjustment parses its tone space in: a targeted layer mask,
|
||||
/// saved channel or Quick Mask holds gray levels whatever the document's mode, so Curves and
|
||||
/// Levels stay in the neutral space instead of Lab (where the composite is ignored) or CMYK
|
||||
/// (where the curve runs on the inks) (#5335).
|
||||
fn adjust_mode(s: &Session, p: &Value) -> ColorMode {
|
||||
if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { ColorMode::Grayscale } else { doc_mode(s) }
|
||||
}
|
||||
|
||||
/// Tone params for a gray plane target: the document-space channel records are dropped, because
|
||||
/// those keys select the Lab or CMYK tone space and a plane only holds levels (#5335).
|
||||
fn gray_tone_params(p: &Value) -> Value {
|
||||
match p {
|
||||
Value::Object(m) => {
|
||||
let mut o = Map::new();
|
||||
for (k, v) in m {
|
||||
if !matches!(k.as_str(), "cyan" | "magenta" | "yellow" | "black" | "lightness" | "a" | "b") {
|
||||
o.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the table ----------
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
|
|
@ -668,7 +732,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Select Layer",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id,"mode":"replace|toggle|range|add|remove"="replace"} (toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##,
|
||||
r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##,
|
||||
has_doc,
|
||||
crate::layer_multi_cmds::select
|
||||
),
|
||||
|
|
@ -677,10 +741,32 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Layer Properties",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
|
||||
r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
|
||||
has_layer,
|
||||
|s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
// Several layers at once (the Layers panel with several selected, as in Photoshop),
|
||||
// else the given or active one.
|
||||
let ids: Vec<LayerId> = match p.get("layers") {
|
||||
None | Some(Value::Null) => vec![layer_param(s, p)?],
|
||||
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => {
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let mut ids = Vec::with_capacity(a.len());
|
||||
for v in a {
|
||||
let id = v.as_u64().map(LayerId).ok_or_else(|| bad("layer.setProps", format!("`layers` holds layer ids, got {v}")))?;
|
||||
if doc.layer(id).is_none() {
|
||||
return Err(EngineError::NoLayer(id));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
Some(v) => return Err(bad("layer.setProps", format!("`layers` is a non-empty list of layer ids, got {v}"))),
|
||||
};
|
||||
if ids.len() > 1 && p.get("name").is_some() {
|
||||
return Err(bad("layer.setProps", "`name` renames one layer, not several"));
|
||||
}
|
||||
// Opacity and Fill are fractions here, while the Layer Style commands take
|
||||
// percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242).
|
||||
let unit = |key: &str| -> Result<Option<f32>> {
|
||||
|
|
@ -691,9 +777,19 @@ fn build() -> Vec<CommandSpec> {
|
|||
}
|
||||
};
|
||||
let (opacity, fill) = (unit("opacity")?, unit("fill")?);
|
||||
let only_visibility =
|
||||
p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce")));
|
||||
let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" };
|
||||
// Photoshop's History names for a single kind of change; a mix is "Layer Properties".
|
||||
let changed: Vec<&str> = p
|
||||
.as_object()
|
||||
.map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect())
|
||||
.unwrap_or_default();
|
||||
let label = match changed.as_slice() {
|
||||
["visible"] => "Layer Visibility",
|
||||
["opacity"] => "Opacity Change",
|
||||
["fill"] => "Fill Opacity Change",
|
||||
["blend"] => "Blending Change",
|
||||
["name"] => "Rename Layer",
|
||||
_ => "Layer Properties",
|
||||
};
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
// Clipping and channel changes reach the layers around it: recomposite everything.
|
||||
let local = p.get("clipped").is_none() && p.get("channels").is_none();
|
||||
|
|
@ -702,47 +798,54 @@ fn build() -> Vec<CommandSpec> {
|
|||
// one that changes its look turns it into a normal layer ("Layer 0") first.
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0);
|
||||
let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal);
|
||||
if num("opacity") || num("fill") || blends {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
l.name = v.to_string();
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
l.visible = v;
|
||||
}
|
||||
if let Some(v) = opacity {
|
||||
l.opacity = v;
|
||||
}
|
||||
if let Some(v) = fill {
|
||||
l.fill_opacity = v;
|
||||
}
|
||||
if let Some(v) = p.get("blend").and_then(Value::as_str) {
|
||||
l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
|
||||
}
|
||||
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
|
||||
l.clipped = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
l.locks.all = v;
|
||||
}
|
||||
if let Some(Value::Array(ch)) = p.get("channels") {
|
||||
// Blending Options › Advanced Blending › Channels: unchecked = left out.
|
||||
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("locks") {
|
||||
for (k, v) in m {
|
||||
let v = v.as_bool().unwrap_or(false);
|
||||
if !l.locks.set(k, v) {
|
||||
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
|
||||
for &id in &ids {
|
||||
if num("opacity") || num("fill") || blends {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
l.name = v.to_string();
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
l.visible = v;
|
||||
}
|
||||
if let Some(v) = opacity {
|
||||
l.opacity = v;
|
||||
}
|
||||
if let Some(v) = fill {
|
||||
l.fill_opacity = v;
|
||||
}
|
||||
if let Some(v) = p.get("blend").and_then(Value::as_str) {
|
||||
let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
|
||||
// Pass Through is a group's mode only, as in Photoshop.
|
||||
if b == photocraft_color::BlendMode::PassThrough && !l.is_group() {
|
||||
return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0)));
|
||||
}
|
||||
l.blend = b;
|
||||
}
|
||||
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
|
||||
l.clipped = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
l.locks.all = v;
|
||||
}
|
||||
if let Some(Value::Array(ch)) = p.get("channels") {
|
||||
// Blending Options › Advanced Blending › Channels: unchecked = left out.
|
||||
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
|
||||
}
|
||||
if let Some(Value::Object(m)) = p.get("locks") {
|
||||
for (k, v) in m {
|
||||
let v = v.as_bool().unwrap_or(false);
|
||||
if !l.locks.set(k, v) {
|
||||
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if local {
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &ids);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
|
@ -1247,7 +1350,16 @@ fn build() -> Vec<CommandSpec> {
|
|||
});
|
||||
let adj = match eyedropped {
|
||||
Some(adj) => adj,
|
||||
None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?,
|
||||
None => {
|
||||
// A gray plane target keeps the neutral tone space even over params
|
||||
// carrying Lab or CMYK records (#5335).
|
||||
let tone = if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) {
|
||||
if matches!(kind.as_str(), "levels" | "curves") { gray_tone_params(p) } else { p.clone() }
|
||||
} else {
|
||||
p.clone()
|
||||
};
|
||||
crate::adjust_params::from_params(&kind, &tone, None, adjust_mode(s, p))?
|
||||
}
|
||||
};
|
||||
let label = adj.label().to_string();
|
||||
// A smart object records the adjustment as a smart filter (Photoshop), which
|
||||
|
|
@ -1261,6 +1373,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
});
|
||||
}
|
||||
v.extend(crate::layer_style::specs());
|
||||
v.extend(crate::layer_list_cmds::specs());
|
||||
v.extend(crate::filters::specs());
|
||||
v.extend(crate::filters_ext::specs());
|
||||
v.extend(crate::color_to_alpha_cmds::specs());
|
||||
|
|
@ -1334,6 +1447,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::proof_sim::specs());
|
||||
v.extend(crate::presets::specs());
|
||||
v.extend(crate::document_preset_cmds::specs());
|
||||
v.extend(crate::export_preset_cmds::specs());
|
||||
v.extend(crate::render_cmds::specs());
|
||||
v.extend(crate::slice_cmds::specs());
|
||||
v.extend(crate::web_cmds::specs());
|
||||
|
|
@ -1550,7 +1664,7 @@ fn merge_group(s: &mut Session, id: LayerId) -> Result<Value> {
|
|||
solo.clipped = false;
|
||||
let mut tmp = doc.clone();
|
||||
tmp.layers = vec![solo];
|
||||
let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None)));
|
||||
let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers_uncropped(&tmp, fmt)));
|
||||
merged.id = group.id;
|
||||
merged.opacity = group.opacity;
|
||||
merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend };
|
||||
|
|
|
|||
213
crates/engine/src/crop_fill.rs
Normal file
213
crates/engine/src/crop_fill.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! The Crop tool's Fill: Content-Aware Fill (#3823). After a rotated crop or one that extends past
|
||||
//! the canvas, the area the original image doesn't cover (the corners a rotation uncovers, the
|
||||
//! strips an extension adds) is filled from its surroundings instead of holding the background
|
||||
//! colour or transparency.
|
||||
//!
|
||||
//! Which layer: the Background when the document has one (the layer the crop filled with the
|
||||
//! background colour), otherwise the active pixel layer (where the crop left transparency). Other
|
||||
//! layers keep their transparency there, as after a plain crop. Each connected piece of the
|
||||
//! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling
|
||||
//! window around that piece, and only pixels the image covers are sampled.
|
||||
|
||||
use photocraft_doc::{Document, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// Seed for the patch search, so a crop fills the same way every time.
|
||||
const SEED: u64 = 1;
|
||||
|
||||
/// The largest canvas (pixels) the uncovered area is worked out for: the Content-Aware window cap.
|
||||
const MAX_PIXELS: u64 = 400_000_000;
|
||||
|
||||
/// Where the cropped canvas came from in the original document.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Coverage {
|
||||
/// Cropped canvas → original document: `[a, b, c, d, e, f]` with
|
||||
/// `x = a·u + b·v + e`, `y = c·u + d·v + f`.
|
||||
pub map: [f64; 6],
|
||||
/// The original canvas size (px).
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// Whether the map rotates (its resampled edge is antialiased against the background colour).
|
||||
pub rotated: bool,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
/// An unrotated crop to `r` (original document px).
|
||||
pub fn crop(r: Rect, width: u32, height: u32) -> Self {
|
||||
Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false }
|
||||
}
|
||||
|
||||
/// Whether the cropped canvas's pixel (u, v) is the original image's: its centre maps inside
|
||||
/// the original canvas by at least half the pixel's projected size (exact for an unrotated
|
||||
/// crop); a rotated crop also refills the one-pixel antialiased edge, which the resampling
|
||||
/// blended with the background colour.
|
||||
fn covered(&self, u: i32, v: i32, margin: f64) -> bool {
|
||||
let [a, b, c, d, e, f] = self.map;
|
||||
let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5);
|
||||
let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f);
|
||||
x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin
|
||||
}
|
||||
|
||||
fn margin(&self) -> f64 {
|
||||
let [a, b, ..] = self.map;
|
||||
0.5 * (a.abs() + b.abs()) + if self.rotated { 1.0 } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Labels of the uncovered pixels: 0 covered, `UNLABELLED` uncovered before labelling, else the
|
||||
/// component number (1-based).
|
||||
const UNLABELLED: u32 = u32::MAX;
|
||||
|
||||
/// Fills the area of the cropped `doc` that `cov` says the original image doesn't cover (see the
|
||||
/// module docs). Nothing uncovered: nothing changes. Errors leave `doc` to be rolled back by the
|
||||
/// caller's history step.
|
||||
pub(crate) fn fill_uncovered(doc: &mut Document, active: Option<LayerId>, cov: &Coverage) -> Result<()> {
|
||||
let canvas = doc.bounds();
|
||||
let (w, h) = (canvas.width(), canvas.height());
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
return Err(EngineError::Other(format!("the {w} x {h} crop is too large for Content-Aware fill")));
|
||||
}
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let len = w.checked_mul(h).ok_or_else(|| EngineError::Other("the crop is too large for Content-Aware fill".into()))?;
|
||||
if len == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let margin = cov.margin();
|
||||
let mut labels = crate::allocation::filled(len, 0u32, "Content-Aware crop fill")?;
|
||||
labels.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
for (x, l) in row.iter_mut().enumerate() {
|
||||
if !cov.covered(x as i32, y as i32, margin) {
|
||||
*l = UNLABELLED;
|
||||
}
|
||||
}
|
||||
});
|
||||
if !labels.contains(&UNLABELLED) {
|
||||
return Ok(());
|
||||
}
|
||||
if !labels.contains(&0) {
|
||||
return Err(EngineError::Other("Content-Aware fill needs some of the image inside the crop to sample from".into()));
|
||||
}
|
||||
let id = target_layer(doc, active)?;
|
||||
let pieces = label_components(&mut labels, w, h)?;
|
||||
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
|
||||
let label = |x: i32, y: i32| -> u32 {
|
||||
if x < 0 || y < 0 {
|
||||
return 0;
|
||||
}
|
||||
(y as usize).checked_mul(w).and_then(|i| i.checked_add(x as usize)).and_then(|i| labels.get(i)).copied().unwrap_or(0)
|
||||
};
|
||||
let n = surf.format().channels().max(1);
|
||||
let filled: Vec<(Rect, Vec<f32>)> = pieces
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.map(|(k, bounds)| -> Result<(Rect, Vec<f32>)> {
|
||||
let k = k as u32 + 1;
|
||||
let (window, px) = crate::fill_cmds::content_aware_native(surf, *bounds, canvas, true, SEED, |x, y| label(x, y) == k, |x, y| label(x, y) == 0)?;
|
||||
// The piece's bounds, its pixels from the fill and the rest as they were.
|
||||
let mut out = surf.read_region(*bounds);
|
||||
let ww = window.width() as usize;
|
||||
for y in bounds.y0..bounds.y1 {
|
||||
for x in bounds.x0..bounds.x1 {
|
||||
if label(x, y) != k || !window.contains(x, y) {
|
||||
continue;
|
||||
}
|
||||
let src = ((y - window.y0) as usize * ww + (x - window.x0) as usize) * n;
|
||||
let dst = ((y - bounds.y0) as usize * bounds.width() as usize + (x - bounds.x0) as usize) * n;
|
||||
if let (Some(s), Some(d)) = (px.get(src..src + n), out.get_mut(dst..dst + n)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((*bounds, out))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
|
||||
for (r, px) in filled {
|
||||
surf.write_region(r, &px);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Background, else the active pixel layer.
|
||||
fn target_layer(doc: &Document, active: Option<LayerId>) -> Result<LayerId> {
|
||||
if let Some(bg) = doc.background() {
|
||||
return Ok(bg.id);
|
||||
}
|
||||
active
|
||||
.filter(|id| doc.layer(*id).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Raster(_))))
|
||||
.ok_or_else(|| EngineError::Other("Content-Aware crop fill needs a Background or an active pixel layer".into()))
|
||||
}
|
||||
|
||||
/// Numbers the 8-connected pieces of `UNLABELLED` pixels 1, 2, … and returns each one's bounds.
|
||||
fn label_components(labels: &mut [u32], w: usize, h: usize) -> Result<Vec<Rect>> {
|
||||
let mut pieces: Vec<Rect> = Vec::new();
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
for start in 0..labels.len() {
|
||||
if labels.get(start) != Some(&UNLABELLED) {
|
||||
continue;
|
||||
}
|
||||
let k = u32::try_from(pieces.len() + 1).ok().filter(|k| *k < UNLABELLED).ok_or_else(|| EngineError::Other("too many uncovered areas".into()))?;
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
if let Some(l) = labels.get_mut(start) {
|
||||
*l = k;
|
||||
}
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
for dy in -1i64..=1 {
|
||||
for dx in -1i64..=1 {
|
||||
let (nx, ny) = (x as i64 + dx, y as i64 + dy);
|
||||
if (dx, dy) == (0, 0) || nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
|
||||
continue;
|
||||
}
|
||||
let j = ny as usize * w + nx as usize;
|
||||
if let Some(l) = labels.get_mut(j)
|
||||
&& *l == UNLABELLED
|
||||
{
|
||||
*l = k;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pieces.push(Rect::new(x0 as i32, y0 as i32, x1 as i32 + 1, y1 as i32 + 1));
|
||||
}
|
||||
Ok(pieces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn components_are_8_connected_with_their_bounds() {
|
||||
let (w, h) = (6, 4);
|
||||
let u = UNLABELLED;
|
||||
#[rustfmt::skip]
|
||||
let mut l = vec![
|
||||
u, 0, 0, 0, 0, u,
|
||||
0, u, 0, 0, 0, u,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, u, u, 0,
|
||||
];
|
||||
let pieces = label_components(&mut l, w, h).unwrap();
|
||||
assert_eq!(pieces, vec![Rect::new(0, 0, 2, 2), Rect::new(5, 0, 6, 2), Rect::new(3, 3, 5, 4)]);
|
||||
assert_eq!(l[7], 1);
|
||||
assert_eq!(l[11], 2);
|
||||
assert_eq!(l[22], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrotated_coverage_is_exact() {
|
||||
let c = Coverage::crop(Rect::new(-2, 0, 8, 4), 5, 4);
|
||||
let m = c.margin();
|
||||
assert!(!c.covered(1, 0, m), "x = -1");
|
||||
assert!(c.covered(2, 0, m), "x = 0");
|
||||
assert!(c.covered(6, 0, m), "x = 4");
|
||||
assert!(!c.covered(7, 0, m), "x = 5");
|
||||
}
|
||||
}
|
||||
|
|
@ -835,6 +835,44 @@ fn guide_color(p: &Value) -> Result<Option<[u8; 3]>> {
|
|||
)
|
||||
}
|
||||
|
||||
/// Edit Selected Guides (the guide context menu): change one guide's orientation, position and
|
||||
/// colour in one history step. A guide that keeps its orientation keeps its index; one that turns
|
||||
/// goes to the end of the other list. Without `color` it keeps its own colour, or none.
|
||||
fn edit_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let orientation = |key: &str| match p.get(key).map(|v| v.as_str()) {
|
||||
None => Ok(None),
|
||||
Some(Some("horizontal")) => Ok(Some(false)),
|
||||
Some(Some("vertical")) => Ok(Some(true)),
|
||||
Some(_) => Err(EngineError::Other(format!("`{key}` must be \"horizontal\" or \"vertical\""))),
|
||||
};
|
||||
let from = orientation("orientation")?.unwrap_or(false);
|
||||
let to = orientation("newOrientation")?.unwrap_or(from);
|
||||
let index = p.get("index").and_then(Value::as_u64).ok_or(EngineError::Other("missing `index`".into()))?;
|
||||
let pos = guide_position(s, p, to)?;
|
||||
let color = guide_color(p)?;
|
||||
s.edit("Edit Guide", |doc, _| {
|
||||
let guides = &mut doc.guides;
|
||||
let list = if from { &guides.vertical } else { &guides.horizontal };
|
||||
let i = usize::try_from(index).ok().filter(|i| *i < list.len()).ok_or(EngineError::Other("no such guide".into()))?;
|
||||
let old = list[i];
|
||||
let at = pos.unwrap_or(old);
|
||||
let rgb = color.or_else(|| guides.color(from, old));
|
||||
if from == to {
|
||||
let list = if from { &mut guides.vertical } else { &mut guides.horizontal };
|
||||
list[i] = at;
|
||||
} else {
|
||||
(if from { &mut guides.vertical } else { &mut guides.horizontal }).remove(i);
|
||||
(if to { &mut guides.vertical } else { &mut guides.horizontal }).push(at);
|
||||
}
|
||||
guides.prune_colors();
|
||||
if rgb.is_some() {
|
||||
guides.set_color(to, at, rgb);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states).
|
||||
fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical";
|
||||
|
|
@ -1014,6 +1052,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
|s, p| guide_cmd(s, p, "move")
|
||||
),
|
||||
spec!(
|
||||
"view.editGuide",
|
||||
"Edit Guide",
|
||||
&[],
|
||||
None,
|
||||
r##"{"orientation":"horizontal|vertical","index":n,"newOrientation":"horizontal|vertical"?,"position":px or "<number><unit>"?,"color":name or "#rrggbb"?} (Edit Selected Guides: units and colours as for view.newGuide; % is of the new orientation's extent; whatever is left out stays as it was)"##,
|
||||
has_doc,
|
||||
edit_guide
|
||||
),
|
||||
spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")),
|
||||
spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")),
|
||||
spec!(
|
||||
|
|
@ -2082,6 +2129,60 @@ mod tests {
|
|||
assert_eq!(s.active().unwrap().history.can_undo(), undo_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_guide_changes_what_is_given_in_one_undoable_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
|
||||
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "magenta"})).unwrap();
|
||||
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30})).unwrap();
|
||||
let guides = |s: &Session| s.active().unwrap().doc.guides.clone();
|
||||
// Position only: the guide keeps its index and its own colour.
|
||||
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "50%"})).unwrap();
|
||||
let g = guides(&s);
|
||||
assert_eq!(g.vertical, vec![10.0, 200.0, 30.0]);
|
||||
assert_eq!((g.color(true, 20.0), g.color(true, 200.0)), (None, Some([0xff, 0x00, 0xff])));
|
||||
// Colour only: an uncoloured guide gets one; the position stays.
|
||||
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "color": "#123456"})).unwrap();
|
||||
assert_eq!(guides(&s).color(true, 10.0), Some([0x12, 0x34, 0x56]));
|
||||
// Turning it: % is of the new orientation's extent, and the colour goes along.
|
||||
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "newOrientation": "horizontal", "position": "25%"})).unwrap();
|
||||
let g = guides(&s);
|
||||
assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![10.0, 30.0], vec![50.0]));
|
||||
assert_eq!((g.color(true, 200.0), g.color(false, 50.0)), (None, Some([0xff, 0x00, 0xff])));
|
||||
// One step each: undo restores the vertical guide with its colour.
|
||||
s.undo();
|
||||
let g = guides(&s);
|
||||
assert_eq!((g.vertical.clone(), g.horizontal.is_empty()), (vec![10.0, 200.0, 30.0], true));
|
||||
assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_guide_edits_are_refused_without_a_history_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10, "color": "green"})).unwrap();
|
||||
let before = s.active().unwrap().doc.guides.clone();
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"orientation": "vertical"}),
|
||||
json!({"orientation": "vertical", "index": 1}),
|
||||
json!({"orientation": "horizontal", "index": 0}),
|
||||
json!({"orientation": "vertical", "index": -1}),
|
||||
json!({"orientation": "vertical", "index": "0"}),
|
||||
json!({"orientation": "vertical", "index": u64::MAX}),
|
||||
json!({"orientation": "diagonal", "index": 0}),
|
||||
json!({"orientation": "vertical", "index": 0, "newOrientation": 3}),
|
||||
json!({"orientation": "vertical", "index": 0, "position": "far"}),
|
||||
json!({"orientation": "vertical", "index": 0, "position": 1e12}),
|
||||
json!({"orientation": "vertical", "index": 0, "color": "puce"}),
|
||||
] {
|
||||
assert!(s.execute("view.editGuide", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().doc.guides, before);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("New Guide"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_guide_positions_are_refused_without_a_guide() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -224,7 +224,13 @@ fn fade(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn history_bytes(s: &Session, all: bool) -> usize {
|
||||
let docs: Vec<&crate::DocState> = if all { s.documents().iter().collect() } else { s.active().into_iter().collect() };
|
||||
docs.iter().map(|d| d.history.unique_bytes(&d.doc)).sum()
|
||||
// Purge keeps the snapshots, so the tiles they share with the states aren't freed.
|
||||
docs.iter()
|
||||
.map(|d| {
|
||||
let snaps: Vec<&photocraft_doc::Document> = d.snapshots.iter().map(|s| &*s.doc).collect();
|
||||
d.history.unique_bytes_besides(&d.doc, &snaps)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn clip_bytes(s: &Session) -> usize {
|
||||
|
|
|
|||
290
crates/engine/src/export_preset_cmds.rs
Normal file
290
crates/engine/src/export_preset_cmds.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
//! Named Export As configurations. They persist with the other presets in the preferences
|
||||
//! document; applying one only returns settings, and the export itself stays in Export As.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::presets::{always, bad, req_str};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const MAX_PRESETS: usize = 256;
|
||||
const MAX_PX: u32 = 300_000;
|
||||
const FORMATS: [&str; 6] = ["png", "jpg", "webp", "tif", "tga", "ico"];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportPresetSettings {
|
||||
pub format: String,
|
||||
pub quality: u8,
|
||||
pub lossless: bool,
|
||||
pub transparency: bool,
|
||||
pub scale: f64,
|
||||
/// Output pixels. Ignored when [`Self::sizing`] is `"scale"`.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub lock_aspect: bool,
|
||||
/// `"scale"` uses [`Self::scale`] on the current document; `"pixels"` uses width and height.
|
||||
pub sizing: String,
|
||||
/// `"none"` drops XMP and EXIF/resolution, `"exif"` keeps EXIF and resolution, `"all"` keeps both.
|
||||
pub metadata: String,
|
||||
/// `"document"` keeps the document profile; `"srgb"` converts to sRGB before export.
|
||||
pub profile: String,
|
||||
pub embed_profile: bool,
|
||||
/// `"auto"`, `"24"` or `"32"`.
|
||||
pub tga_bits: String,
|
||||
}
|
||||
|
||||
impl ExportPresetSettings {
|
||||
fn validate(&self) -> std::result::Result<(), String> {
|
||||
if !FORMATS.contains(&self.format.as_str()) {
|
||||
return Err(format!("format must be one of {}", FORMATS.join(", ")));
|
||||
}
|
||||
if !(1..=100).contains(&self.quality) {
|
||||
return Err("quality must be within 1..=100".into());
|
||||
}
|
||||
if !self.scale.is_finite() || !(1.0..=1000.0).contains(&self.scale) {
|
||||
return Err("scale must be within 1..=1000 percent".into());
|
||||
}
|
||||
if !matches!(self.sizing.as_str(), "scale" | "pixels") {
|
||||
return Err("sizing must be scale or pixels".into());
|
||||
}
|
||||
if self.sizing == "pixels" && (!(1..=MAX_PX).contains(&self.width) || !(1..=MAX_PX).contains(&self.height)) {
|
||||
return Err(format!("width and height must be within 1..={MAX_PX} pixels"));
|
||||
}
|
||||
if !matches!(self.metadata.as_str(), "none" | "exif" | "all") {
|
||||
return Err("metadata must be none, exif or all".into());
|
||||
}
|
||||
if !matches!(self.profile.as_str(), "document" | "srgb") {
|
||||
return Err("profile must be document or srgb".into());
|
||||
}
|
||||
if !matches!(self.tga_bits.as_str(), "auto" | "24" | "32") {
|
||||
return Err("tgaBits must be auto, 24 or 32".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn from_params(p: &Value, cmd: &str) -> Result<Self> {
|
||||
let mut fields = p.as_object().cloned().ok_or_else(|| bad(cmd, "`settings` must be an object"))?;
|
||||
let defaults = json!({
|
||||
"format": "png",
|
||||
"quality": 85,
|
||||
"lossless": false,
|
||||
"transparency": true,
|
||||
"scale": 100.0,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"lockAspect": true,
|
||||
"sizing": "scale",
|
||||
"metadata": "none",
|
||||
"profile": "document",
|
||||
"embedProfile": true,
|
||||
"tgaBits": "auto"
|
||||
});
|
||||
if let Some(defaults) = defaults.as_object() {
|
||||
for (k, v) in defaults {
|
||||
fields.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
for key in ["width", "height"] {
|
||||
if let Some(n) = fields.get(key).and_then(Value::as_f64) {
|
||||
if !n.is_finite() || n < 0.0 || n > f64::from(MAX_PX) {
|
||||
return Err(bad(cmd, format!("`{key}` must be within 0..={MAX_PX} pixels")));
|
||||
}
|
||||
fields.insert(key.into(), json!(n.round() as u32));
|
||||
}
|
||||
}
|
||||
if let Some(q) = fields.get("quality").and_then(Value::as_f64) {
|
||||
if !q.is_finite() || !(1.0..=100.0).contains(&q) {
|
||||
return Err(bad(cmd, "`quality` must be within 1..=100"));
|
||||
}
|
||||
fields.insert("quality".into(), json!(q.round() as u8));
|
||||
}
|
||||
let settings: Self = serde_json::from_value(Value::Object(fields)).map_err(|e| bad(cmd, format!("invalid settings: {e}")))?;
|
||||
settings.validate().map_err(|e| bad(cmd, e))?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExportPreset {
|
||||
pub name: String,
|
||||
pub settings: ExportPresetSettings,
|
||||
}
|
||||
|
||||
fn checked_name(name: &str) -> std::result::Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > crate::brush_preset_cmds::MAX_NAME {
|
||||
return Err("Preset names must contain 1–255 characters after trimming".into());
|
||||
}
|
||||
Ok(name.into())
|
||||
}
|
||||
|
||||
/// Read entries independently so a damaged preset cannot discard other presets or preferences.
|
||||
pub(crate) fn load(value: Value) -> Vec<ExportPreset> {
|
||||
let Some(entries) = value.as_array() else { return Vec::new() };
|
||||
let mut presets: Vec<ExportPreset> = Vec::new();
|
||||
for entry in entries.iter().take(MAX_PRESETS) {
|
||||
let Ok(mut preset) = serde_json::from_value::<ExportPreset>(entry.clone()) else { continue };
|
||||
let Ok(name) = checked_name(&preset.name) else { continue };
|
||||
if preset.settings.validate().is_err() || presets.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) {
|
||||
continue;
|
||||
}
|
||||
preset.name = name;
|
||||
presets.push(preset);
|
||||
}
|
||||
presets
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "file.export.presets.save";
|
||||
let name = checked_name(req_str(p, "name", CMD)?).map_err(|e| bad(CMD, e))?;
|
||||
if s.presets.export_presets.len() >= MAX_PRESETS && s.presets.export_presets.iter().all(|pr| !pr.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(bad(CMD, format!("At most {MAX_PRESETS} export presets can be saved")));
|
||||
}
|
||||
let settings = ExportPresetSettings::from_params(p.get("settings").unwrap_or(&Value::Null), CMD)?;
|
||||
let preset = ExportPreset { name: name.clone(), settings };
|
||||
if let Some(existing) = s.presets.export_presets.iter_mut().find(|pr| pr.name.eq_ignore_ascii_case(&name)) {
|
||||
*existing = preset.clone();
|
||||
} else {
|
||||
s.presets.export_presets.push(preset.clone());
|
||||
}
|
||||
s.presets_changed();
|
||||
Ok(json!({"preset": preset}))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "file.export.presets.get";
|
||||
let name = req_str(p, "name", CMD)?;
|
||||
let preset = s.presets.export_presets.iter().find(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such export preset"))?;
|
||||
Ok(json!({"preset": preset}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "file.export.presets.delete";
|
||||
let name = req_str(p, "name", CMD)?;
|
||||
let index = s.presets.export_presets.iter().position(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such export preset"))?;
|
||||
s.presets.export_presets.remove(index);
|
||||
s.presets_changed();
|
||||
Ok(json!({"deleted": name}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "file.export.presets.list",
|
||||
label: "List Export Presets",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{}",
|
||||
enabled: always,
|
||||
run: |s, _| Ok(json!({"presets": s.presets.export_presets})),
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "file.export.presets.get",
|
||||
label: "Get Export Preset",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"name":str} -> {preset} (settings for Export As; nothing is exported)"#,
|
||||
enabled: always,
|
||||
run: get,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "file.export.presets.save",
|
||||
label: "Save Export Preset",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"name":str,"settings":{"format":"png|jpg|webp|tif|tga|ico","quality":1..100,"lossless":bool,"transparency":bool,"scale":1..1000,"width":px,"height":px,"lockAspect":bool,"sizing":"scale|pixels","metadata":"none|exif|all","profile":"document|srgb","embedProfile":bool,"tgaBits":"auto|24|32"}} (a saved name is replaced)"#,
|
||||
enabled: always,
|
||||
run: save,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "file.export.presets.delete",
|
||||
label: "Delete Export Preset",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"name":str}"#,
|
||||
enabled: always,
|
||||
run: delete,
|
||||
journal: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(sizing: &str) -> Value {
|
||||
json!({
|
||||
"format": "jpg",
|
||||
"quality": 70,
|
||||
"lossless": false,
|
||||
"transparency": false,
|
||||
"scale": 50,
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"lockAspect": true,
|
||||
"sizing": sizing,
|
||||
"metadata": "exif",
|
||||
"profile": "srgb",
|
||||
"embedProfile": false,
|
||||
"tgaBits": "24"
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_round_trip_through_the_preferences_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.export.presets.save", json!({"name": " Web JPEG ", "settings": settings("pixels")})).unwrap();
|
||||
let again = s.execute("file.export.presets.save", json!({"name": "web jpeg", "settings": settings("scale")})).unwrap();
|
||||
assert_eq!(again["preset"]["settings"]["sizing"], "scale");
|
||||
assert_eq!(s.presets.export_presets.len(), 1, "saving the same name replaces it");
|
||||
|
||||
let raw = s.prefs_to_json();
|
||||
let mut restored = Session::new();
|
||||
restored.load_prefs_json(&raw).unwrap();
|
||||
let listed = restored.execute("file.export.presets.list", json!({})).unwrap();
|
||||
let presets = listed["presets"].as_array().unwrap();
|
||||
assert_eq!(presets.len(), 1);
|
||||
assert_eq!(presets[0]["name"], "web jpeg");
|
||||
assert_eq!(presets[0]["settings"]["format"], "jpg");
|
||||
assert_eq!(presets[0]["settings"]["width"], 640);
|
||||
assert_eq!(presets[0]["settings"]["embedProfile"], false);
|
||||
assert_eq!(presets[0]["settings"]["metadata"], "exif");
|
||||
|
||||
let got = restored.execute("file.export.presets.get", json!({"name": "WEB JPEG"})).unwrap();
|
||||
assert_eq!(got["preset"]["settings"]["profile"], "srgb");
|
||||
restored.execute("file.export.presets.delete", json!({"name": "web jpeg"})).unwrap();
|
||||
assert!(restored.execute("file.export.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().is_empty());
|
||||
let raw = restored.prefs_to_json();
|
||||
let mut after = Session::new();
|
||||
after.load_prefs_json(&raw).unwrap();
|
||||
assert!(after.presets.export_presets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_presets_are_rejected_and_a_damaged_entry_is_dropped() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("file.export.presets.save", json!({})).is_err());
|
||||
assert!(s.execute("file.export.presets.save", json!({"name": " ", "settings": settings("scale")})).is_err());
|
||||
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"format": "bmp"}})).is_err());
|
||||
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"sizing": "pixels", "width": 0, "height": 10}})).is_err());
|
||||
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"scale": 0}})).is_err());
|
||||
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"metadata": "copyright"}})).is_err());
|
||||
assert!(s.execute("file.export.presets.save", json!({"name": "x", "settings": {"profile": "fogra39"}})).is_err());
|
||||
assert!(s.execute("file.export.presets.delete", json!({"name": "missing"})).is_err());
|
||||
assert!(s.execute("file.export.presets.get", json!({"name": ""})).is_err());
|
||||
|
||||
let loaded = load(json!([
|
||||
{"name": "ok", "settings": {"format": "png", "quality": 85, "lossless": false, "transparency": true, "scale": 100.0, "width": 0, "height": 0, "lockAspect": true, "sizing": "scale", "metadata": "none", "profile": "document", "embedProfile": true, "tgaBits": "auto"}},
|
||||
{"name": "broken", "settings": {"format": "nope"}},
|
||||
"not an object"
|
||||
]));
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].name, "ok");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user