mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 05:06:55 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a76831f632 | ||
|
|
5cf4689ea7 |
No files matched your search
+1
-10
@@ -1,6 +1,5 @@
|
||||
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
|
||||
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
|
||||
# keeps working (#2204).
|
||||
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
|
||||
#
|
||||
# cargo nextest run --workspace # all tests except doctests
|
||||
# cargo test --doc --workspace # nextest does not run doctests
|
||||
@@ -14,11 +13,3 @@ gpu = { max-threads = 4 }
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
|
||||
test-group = 'gpu'
|
||||
|
||||
# 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"
|
||||
+21
-47
@@ -39,7 +39,6 @@ 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:
|
||||
@@ -66,7 +65,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(-boot)?/|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/|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"
|
||||
@@ -78,7 +77,6 @@ jobs:
|
||||
fi
|
||||
|
||||
test:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.os }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -113,52 +111,25 @@ jobs:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
|
||||
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
|
||||
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
||||
- name: Scorecard up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
|
||||
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
with:
|
||||
tool: [email protected]
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
|
||||
# Separate native commands into steps so Windows reports either command's failure.
|
||||
- name: Test
|
||||
run: cargo nextest run --workspace --locked --features heif --profile ci
|
||||
|
||||
- name: Doctests
|
||||
run: cargo test --doc --workspace --locked --features heif
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
|
||||
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
|
||||
# --features heif` of the Test step: any other package or feature selection resolves a
|
||||
# different feature set, recompiles the whole dependency graph into the same target dir and
|
||||
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
||||
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
|
||||
# not what is built); engine and the apps only pass text on to them (#6120).
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
@@ -172,12 +143,21 @@ jobs:
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: >-
|
||||
cargo nextest run --workspace --locked --features heif --profile ci
|
||||
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
||||
- name: Scorecard up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
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
|
||||
@@ -210,7 +190,6 @@ jobs:
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -227,7 +206,6 @@ 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
|
||||
@@ -251,8 +229,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 builds, validates relative URLs, cuts the wasm into
|
||||
# parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
|
||||
# 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.
|
||||
- name: Build and verify browser release
|
||||
run: packaging/web/package.sh
|
||||
- name: Report wasm size
|
||||
@@ -263,19 +241,15 @@ 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
|
||||
row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
|
||||
bytes=$(wc -c < "$wasm" | tr -d ' ')
|
||||
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
|
||||
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
|
||||
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
|
||||
|
||||
@@ -27,7 +27,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 15
|
||||
name: mdBook build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -23,7 +23,6 @@ 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,7 +39,6 @@ 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
|
||||
```
|
||||
|
||||
@@ -132,6 +131,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
||||
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
|
||||
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
|
||||
- `log/` (local, gitignored): the dev log.
|
||||
-
|
||||
|
||||
## 8. Keeping the native format complete
|
||||
|
||||
|
||||
+2
-4
@@ -13,7 +13,6 @@ 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) |
|
||||
@@ -27,7 +26,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`, `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/*.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/{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 |
|
||||
@@ -42,7 +41,6 @@ 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) |
|
||||
@@ -114,6 +112,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/` (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 |
|
||||
| `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 |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
Generated
+48
-1267
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.8.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -38,7 +38,7 @@ photocraft-testkit = { path = "crates/testkit" }
|
||||
photocraft-tablet = { path = "crates/tablet" }
|
||||
|
||||
# external
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -187,7 +187,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗃️ Layers, done properly</h4>
|
||||
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
|
||||
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -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. 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 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 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:
|
||||
|
||||
|
||||
+3
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
|
||||
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
|
||||
@@ -40,7 +40,7 @@ the one above. About 80% parallelizes (independent crates and command modules);
|
||||
architecture and the PSD writer are the serial parts.
|
||||
|
||||
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
|
||||
end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets
|
||||
end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets
|
||||
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
|
||||
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
|
||||
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
|
||||
@@ -110,7 +110,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
|
||||
|
||||
| Rank | Milestone | Estimate |
|
||||
|---|---|---:|
|
||||
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h |
|
||||
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h |
|
||||
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
|
||||
| 3 | User bug backlog to under 100 open | 250–400 h |
|
||||
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
|
||||
@@ -135,7 +135,6 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
|
||||
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
|
||||
@@ -541,7 +541,9 @@ 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| photocraft_engine::commands::list_entry_matches(c, n)))
|
||||
.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))
|
||||
})
|
||||
.collect();
|
||||
if a.has("--json") {
|
||||
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
|
||||
|
||||
@@ -90,11 +90,6 @@ 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",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
[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"] }
|
||||
@@ -1,6 +0,0 @@
|
||||
# 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
|
||||
@@ -1,30 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,356 +0,0 @@
|
||||
//! 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", "cin", "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", "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,7 +340,20 @@ 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| {
|
||||
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
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())
|
||||
})),
|
||||
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,25 +26,6 @@ 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"))]
|
||||
@@ -64,22 +45,6 @@ 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,12 +5,11 @@ use std::io::{BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
|
||||
read_bounded_line, repeat_auth_reply,
|
||||
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;
|
||||
@@ -48,17 +47,11 @@ 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(AuthDeadlineStream::new(read, auth_deadline));
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut out = stream;
|
||||
let mut line = String::new();
|
||||
let mut authenticated = false;
|
||||
@@ -81,9 +74,6 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
|
||||
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;
|
||||
}
|
||||
@@ -130,34 +120,7 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// #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");
|
||||
}
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
|
||||
|
||||
@@ -233,10 +233,9 @@ 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`]). 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).
|
||||
/// for presenting the window (see [`backends`]).
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
|
||||
let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
|
||||
let ty = match a.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
@@ -636,20 +635,6 @@ 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);
|
||||
|
||||
@@ -78,11 +78,9 @@ 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: viewport
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
|
||||
@@ -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", "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",
|
||||
"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",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -53,7 +53,6 @@ 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"]),
|
||||
@@ -229,7 +228,20 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
|
||||
}
|
||||
|
||||
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
|
||||
settings.io_options()
|
||||
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
|
||||
}
|
||||
|
||||
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
|
||||
@@ -726,8 +738,7 @@ 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() {
|
||||
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
|
||||
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
|
||||
let layered = ["psd", "psb", "pcraft", "ora"];
|
||||
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"));
|
||||
@@ -896,7 +907,6 @@ 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;
|
||||
@@ -917,16 +927,12 @@ 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.iter().any(|e| e.info.revision == revision) {
|
||||
if !recovered.is_empty() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
@@ -64,33 +64,15 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const BTN_TOOL_PEN: u32 = 0x140;
|
||||
|
||||
/// `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).
|
||||
/// 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.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn has_pen(devices: &str) -> bool {
|
||||
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)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
|
||||
@@ -256,49 +238,6 @@ 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,16 +81,17 @@ mod platform {
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE::new(),
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
|
||||
/// How far a parked maximize reaches past its monitor on the left, top and right: 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;
|
||||
|
||||
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
|
||||
/// [`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.
|
||||
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
|
||||
if !hwnd.IsZoomed() {
|
||||
return;
|
||||
@@ -99,58 +100,34 @@ mod platform {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
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 {
|
||||
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 {
|
||||
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 { x: left, y: top },
|
||||
SIZE { cx: width, cy: height },
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
POINT::new(),
|
||||
SIZE { cx: r.right - r.left, cy: height },
|
||||
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
let _ = hwnd.InvalidateRect(None, false);
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
/// 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> {
|
||||
let (l, t, r, b) = rect;
|
||||
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 {
|
||||
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)) {
|
||||
return None;
|
||||
}
|
||||
// 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))
|
||||
{
|
||||
if b <= wb + side {
|
||||
return None;
|
||||
}
|
||||
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
|
||||
(corrected != rect).then_some(corrected)
|
||||
Some(wb + side - t)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
@@ -162,7 +139,7 @@ mod platform {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{borderless, clamped_rect};
|
||||
use super::{borderless, clamped_height};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
@@ -179,47 +156,34 @@ mod platform {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_respects_taskbars_on_every_edge() {
|
||||
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.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
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");
|
||||
}
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
|
||||
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_or_snapped_is_left_alone() {
|
||||
fn a_maximize_being_dragged_away_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");
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 466 B |
@@ -158,7 +158,3 @@ 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.1"
|
||||
ruzstd = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -132,10 +132,8 @@ test fixtures, not an exporter.
|
||||
|
||||
## Safety limits
|
||||
|
||||
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
|
||||
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
|
||||
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, max_pixel_bytes: 64 << 20 });
|
||||
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 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, max_pixel_bytes: 64 << 20 });
|
||||
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
});
|
||||
@@ -26,16 +26,11 @@ 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 {
|
||||
// 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 }
|
||||
Self { max_entry: 256 << 20, max_total: 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, limits).document()
|
||||
Reader::new(&s, &mut archive).document()
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
@@ -298,27 +298,11 @@ 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>, 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(())
|
||||
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() }
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
|
||||
@@ -117,11 +117,6 @@ 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, ..Limits::default() }).unwrap();
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).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, ..Limits::default() }).unwrap();
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
|
||||
a.read("doc.dat").unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
@@ -459,82 +459,3 @@ 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,10 +474,8 @@ 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] <= keep {
|
||||
if shole[i] || dist[i] <= 1.5 {
|
||||
fixed[i] = HARD_FG;
|
||||
continue;
|
||||
}
|
||||
@@ -510,22 +508,6 @@ 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
|
||||
@@ -769,31 +751,6 @@ 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
|
||||
@@ -841,116 +798,6 @@ 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];
|
||||
|
||||
+4
-100
@@ -16,15 +16,6 @@
|
||||
//! 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
|
||||
@@ -287,38 +278,6 @@ 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.
|
||||
@@ -1616,20 +1575,14 @@ 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). A thin hole stops while it
|
||||
// is still about two patches thick: through it, not along it, is what counts (#1366).
|
||||
// structure where the texture has averaged out, and fills flat).
|
||||
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 <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
break;
|
||||
}
|
||||
let d = downsample(l, ch);
|
||||
@@ -1685,21 +1638,9 @@ fn complete_cow<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?,
|
||||
None => {
|
||||
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 unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
|
||||
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
|
||||
for (i, k) in lpx.iter().enumerate() {
|
||||
if *k == Px::Hole {
|
||||
@@ -2173,43 +2114,6 @@ 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() {
|
||||
|
||||
+3
-11
@@ -600,13 +600,6 @@ impl FilterParams {
|
||||
self.halo() == Halo::Bounds
|
||||
}
|
||||
|
||||
/// Whether the filter renders new pixels without reading the layer's own (Fibers): it fills
|
||||
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
|
||||
/// empty layer, while still reading no neighbourhood.
|
||||
pub fn renders_from_nothing(&self) -> bool {
|
||||
matches!(self, FilterParams::Fibers { .. })
|
||||
}
|
||||
|
||||
/// Human-readable name.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -912,13 +905,12 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
pub const TILE: i32 = 256;
|
||||
|
||||
/// The area a filter writes for a layer whose pixels cover `content`:
|
||||
/// global filters and renders that ignore the layer's pixels (Fibers) write the reference
|
||||
/// bounds; others the content grown by the halo (blurs spread into transparent areas).
|
||||
/// Clipped to the selection's bounds when there is one.
|
||||
/// global filters write the reference bounds; others the content grown by
|
||||
/// the halo (blurs spread into transparent areas). Clipped to the
|
||||
/// selection's bounds when there is one.
|
||||
pub fn output_area(params: &FilterParams, content: Rect, bounds: Rect, selection_bounds: Option<Rect>) -> Rect {
|
||||
let mut area = match params.halo_for(bounds) {
|
||||
Halo::Bounds => bounds,
|
||||
Halo::Radius(_) if params.renders_from_nothing() => bounds,
|
||||
Halo::Radius(r) => {
|
||||
if content.is_empty() {
|
||||
Rect::EMPTY
|
||||
|
||||
@@ -448,51 +448,6 @@ 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,17 +13,8 @@
|
||||
//! 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).
|
||||
//! 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.
|
||||
//! are definite background); the result is cleaned (small islands dropped, small holes
|
||||
//! filled) and re-cut at full resolution.
|
||||
//!
|
||||
//! Works well for a distinct object on a comparatively plain background; it is not a semantic
|
||||
//! segmenter.
|
||||
@@ -34,7 +25,6 @@ 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};
|
||||
@@ -82,65 +72,13 @@ 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.0);
|
||||
let low = super::clean_mask(&low, w, h, 0.2, 0.05);
|
||||
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,154 +761,3 @@ 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());
|
||||
}
|
||||
@@ -682,23 +682,18 @@ fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) {
|
||||
|
||||
/// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60,
|
||||
/// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the
|
||||
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma
|
||||
/// (max − min), so neutrals are never selected and muted colours only partly. Darker colours
|
||||
/// weigh more, by `2 − max`: measured against Photoshop 2025's presets on 126 colours each for
|
||||
/// Reds, Yellows and Blues (hue ±40°, saturation 5..100 %, brightness 33..100 %), this matches
|
||||
/// its selection to within one level, where chroma alone fell up to 61 levels short (#5243).
|
||||
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so
|
||||
/// neutrals are never selected and muted colours only partly.
|
||||
pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec<f32> {
|
||||
px.iter()
|
||||
.map(|&[r, g, b, a]| {
|
||||
if a <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let rgb = [r, g, b].map(|v| v.clamp(0.0, 1.0));
|
||||
let (h, chroma) = hue_chroma(rgb);
|
||||
let max = rgb[0].max(rgb[1]).max(rgb[2]);
|
||||
let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0)));
|
||||
let dh = (h - center).rem_euclid(360.0);
|
||||
let dh = dh.min(360.0 - dh);
|
||||
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma * (2.0 - max)).clamp(0.0, 1.0)
|
||||
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1185,17 +1180,6 @@ mod tests {
|
||||
assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_families_weigh_dark_colours_like_photoshop() {
|
||||
// Photoshop 2025's Blues on an 8-bit sRGB document (selection levels 0..255, #5243): the
|
||||
// muted navy #3d3f54, a dark blue (0, 0, 84) and a mid blue (61, 61, 153).
|
||||
let c = |r: u8, g: u8, b: u8| [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0];
|
||||
let blues = hue_range(&[c(0x3d, 0x3f, 0x54), c(0, 0, 84), c(61, 61, 153)], 240.0);
|
||||
for (got, want) in blues.iter().zip([35.0, 140.0, 129.0]) {
|
||||
assert!((got * 255.0 - want).abs() <= 1.0, "{} vs Photoshop {want}", got * 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonal_ranges() {
|
||||
let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0];
|
||||
|
||||
@@ -446,10 +446,6 @@ fn halos_and_output_area() {
|
||||
assert_eq!(output_area(&FilterParams::Twirl { angle: 1.0 }, content, R, None), R);
|
||||
assert_eq!(output_area(&FilterParams::BoxBlur { radius: 1.0 }, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(8, 8, 12, 12));
|
||||
assert!(output_area(&FilterParams::Invert, Rect::EMPTY, R, None).is_empty());
|
||||
// Fibers renders without reading the layer: an empty layer still fills the bounds (#5241).
|
||||
let fibers = FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 0, foreground: [0.0; 4], background: [1.0; 4] };
|
||||
assert_eq!(output_area(&fibers, Rect::EMPTY, R, None), R);
|
||||
assert_eq!(output_area(&fibers, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(0, 0, 12, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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 => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0),
|
||||
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 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).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);
|
||||
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;
|
||||
// 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,15 +392,6 @@ 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)?;
|
||||
@@ -433,7 +424,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 += clamped(ix + dx, iy + dy, c) * wx * wy;
|
||||
*s += at(ix + dx, iy + dy, c) * wx * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,21 +436,12 @@ fn warp(
|
||||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let k = wxi * wyj;
|
||||
for (c, s) in acc.iter_mut().enumerate().take(n) {
|
||||
*s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
|
||||
*s += at(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;
|
||||
@@ -557,24 +539,6 @@ 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
-68
@@ -4,10 +4,8 @@
|
||||
//! 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 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
|
||||
//! 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
|
||||
//! 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
|
||||
@@ -79,11 +77,8 @@ 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()));
|
||||
// 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));
|
||||
let nx = ((sw / CELL).ceil() as usize).clamp(1, MAX_CELLS);
|
||||
let ny = ((sh / CELL).ceil() as usize).clamp(1, MAX_CELLS);
|
||||
// Forward-mapped vertices (and their source positions).
|
||||
let mut verts = Vec::with_capacity((nx + 1) * (ny + 1));
|
||||
for j in 0..=ny {
|
||||
@@ -106,43 +101,6 @@ 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
|
||||
@@ -441,28 +399,6 @@ 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
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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::{
|
||||
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,
|
||||
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,9 +91,17 @@ 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| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect())
|
||||
}
|
||||
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(),
|
||||
),
|
||||
})
|
||||
}
|
||||
"session.list" => Ok(self.session_list()),
|
||||
@@ -262,12 +270,10 @@ pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> {
|
||||
configure_stream(&stream)?;
|
||||
let read = stream.try_clone()?;
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
|
||||
let mut reader = std::io::BufReader::new(read);
|
||||
let mut out = stream;
|
||||
let mut line = String::new();
|
||||
let mut authenticated = false;
|
||||
@@ -292,9 +298,6 @@ 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)?;
|
||||
@@ -327,7 +330,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, AUTH_DEADLINE);
|
||||
let _ = serve_tcp_connection(&h, stream, &token);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -362,7 +365,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 (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`.
|
||||
/// exactly these keys, from `engine.commands` (filtered or not) and `command.list`.
|
||||
#[test]
|
||||
fn command_list_replies_are_documented_array_of_objects() {
|
||||
let h = session();
|
||||
@@ -374,10 +377,8 @@ 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 {
|
||||
// `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();
|
||||
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).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}");
|
||||
@@ -819,60 +820,6 @@ 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, Read, Write};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -21,10 +21,6 @@ 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;
|
||||
@@ -246,44 +242,6 @@ 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());
|
||||
@@ -369,70 +327,6 @@ 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, label or keywords contain this text (case-insensitive).
|
||||
/// Only commands whose id or label contains this text (case-insensitive).
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
/// Only commands that can run right now.
|
||||
@@ -320,15 +320,11 @@ 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), cropFill (background or contentAware,
|
||||
/// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit,
|
||||
/// auto_adjust, classic_mode and auto_center_preview), 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). layerFilter (an object patching the Layers panel filter:
|
||||
/// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string;
|
||||
/// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep
|
||||
/// their current values. Other fields are an error.
|
||||
/// active layer's storage tiles). Other fields are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
@@ -825,8 +821,9 @@ impl PhotocraftMcp {
|
||||
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
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))
|
||||
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))
|
||||
})
|
||||
.collect();
|
||||
Ok(ok_json(&Value::Array(items)))
|
||||
|
||||
@@ -363,7 +363,7 @@ 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" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
|
||||
"actions.load" | "brush.presets.importAbr" | "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()
|
||||
}
|
||||
@@ -758,7 +758,6 @@ 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"})),
|
||||
@@ -773,7 +772,6 @@ 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"})),
|
||||
|
||||
@@ -166,7 +166,7 @@ async fn agent_completes_ten_scripted_tasks() {
|
||||
let outside = pixel(&c, 1, 1).await;
|
||||
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 5: centre is red {inside:?}");
|
||||
assert!(outside[0] < 0.1, "task 5: corner shows the photo {outside:?}");
|
||||
assert_eq!(inspect(&c).await["hasSelection"], false, "task 5: the selection became the mask");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 6. Save a selection to a channel and load it back.
|
||||
run(&c, "select.rect", json!({"x": 4, "y": 6, "width": 20, "height": 10})).await;
|
||||
@@ -413,7 +413,7 @@ async fn agent_completes_ten_more_scripted_tasks() {
|
||||
assert_eq!(top_layer(&doc)["hasMask"], true, "task 17");
|
||||
let boundary = pixel(&c, 20, 40).await;
|
||||
assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}");
|
||||
assert_eq!(doc["hasSelection"], false, "task 17: the selection became the mask");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 18. Expand a selection and read its bounds back.
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await;
|
||||
|
||||
@@ -167,9 +167,6 @@ 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");
|
||||
|
||||
@@ -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.
+12
-22
@@ -26,8 +26,6 @@ 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.
|
||||
@@ -45,13 +43,12 @@ pub enum Builtin {
|
||||
}
|
||||
|
||||
impl Builtin {
|
||||
pub const ALL: [Builtin; 12] = [
|
||||
pub const ALL: [Builtin; 11] = [
|
||||
Builtin::Srgb,
|
||||
Builtin::DisplayP3,
|
||||
Builtin::AdobeRgbCompat,
|
||||
Builtin::ProPhotoCompat,
|
||||
Builtin::LinearSrgb,
|
||||
Builtin::AcesCg,
|
||||
Builtin::Rec2020,
|
||||
Builtin::GrayGamma22,
|
||||
Builtin::SGray,
|
||||
@@ -68,7 +65,6 @@ 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",
|
||||
@@ -83,15 +79,14 @@ 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::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::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::CoatedCmyk => crate::synth::DESCRIPTION,
|
||||
}
|
||||
}
|
||||
@@ -109,7 +104,6 @@ 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,
|
||||
@@ -119,7 +113,7 @@ impl Builtin {
|
||||
|
||||
/// The profile (built once per process).
|
||||
pub fn profile(self) -> &'static Profile {
|
||||
static CELLS: [OnceLock<Profile>; 12] = [const { OnceLock::new() }; 12];
|
||||
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
|
||||
// `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))
|
||||
@@ -146,7 +140,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).
|
||||
@@ -262,13 +256,12 @@ 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, ap1) = (
|
||||
let (srgb_p, p3, adobe, romm, bt2020) = (
|
||||
[[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()),
|
||||
@@ -276,9 +269,6 @@ 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;
|
||||
|
||||
+36
-148
@@ -429,18 +429,10 @@ impl<'a> Reader<'a> {
|
||||
Ok(self.u32(off)? as i32 as f64 / 65536.0)
|
||||
}
|
||||
fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> {
|
||||
let r = Reader { b: self.slice(off, 12)? };
|
||||
Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?])
|
||||
Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 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)
|
||||
}
|
||||
@@ -580,15 +572,14 @@ 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))
|
||||
@@ -693,8 +684,36 @@ 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| lut_ab_matrix(&r, off);
|
||||
let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout);
|
||||
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 mut stages = Vec::new();
|
||||
if ty == b"mAB " {
|
||||
// A curves → CLUT → M curves → matrix → B curves.
|
||||
@@ -741,45 +760,6 @@ 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 };
|
||||
@@ -795,10 +775,8 @@ 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) {
|
||||
// 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 };
|
||||
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 };
|
||||
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") {
|
||||
@@ -814,93 +792,3 @@ 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)]
|
||||
|
||||
+1
-10
@@ -47,7 +47,6 @@ 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` |
|
||||
|
||||
@@ -94,15 +93,7 @@ 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 (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.
|
||||
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
|
||||
|
||||
* **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, alloc_zeroed};
|
||||
use crate::options::Limits;
|
||||
|
||||
const F: Format = Format::OpenExr;
|
||||
|
||||
@@ -39,15 +39,9 @@ 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<'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);
|
||||
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}")))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -87,11 +81,10 @@ 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. 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> {
|
||||
/// holding every second byte of the original; weave them back together.
|
||||
fn interleave_byte_blocks(buf: &mut [u8]) {
|
||||
let half = buf.len().div_ceil(2);
|
||||
let mut out = alloc_zeroed::<u8>(buf.len())?;
|
||||
let mut out = vec![0u8; 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());
|
||||
@@ -112,29 +105,12 @@ fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> {
|
||||
}
|
||||
}
|
||||
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::new();
|
||||
reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?;
|
||||
let mut out = Vec::with_capacity(expected.min(1 << 20));
|
||||
let mut i = 0;
|
||||
while i < src.len() {
|
||||
let count = src[i] as i8;
|
||||
@@ -143,7 +119,6 @@ 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")))?;
|
||||
@@ -168,10 +143,7 @@ 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 {
|
||||
let mut out = Vec::new();
|
||||
reserve_up_to(&mut out, expected, expected, what)?;
|
||||
out.extend_from_slice(packed);
|
||||
return Ok(out);
|
||||
return Ok(packed.to_vec());
|
||||
}
|
||||
match compression {
|
||||
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
|
||||
@@ -179,38 +151,20 @@ 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 => {
|
||||
// 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")));
|
||||
}
|
||||
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")))?;
|
||||
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)"))),
|
||||
@@ -386,41 +340,13 @@ 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 what = "the sample-count table";
|
||||
let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;
|
||||
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
|
||||
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.
|
||||
// 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
|
||||
))
|
||||
})?,
|
||||
};
|
||||
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"))?;
|
||||
|
||||
// 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.
|
||||
@@ -497,7 +423,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;
|
||||
@@ -685,82 +611,3 @@ 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,78 +337,6 @@ 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();
|
||||
@@ -725,60 +653,4 @@ 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,21 +187,6 @@ 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,11 +530,7 @@ 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 {
|
||||
// 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());
|
||||
out.extend_from_slice(&d[idx(p, y, x)].to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,17 +632,6 @@ 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,24 +532,17 @@ 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 | 8 | 9 => 3,
|
||||
2 => 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 | 8 | 9) && alpha_at.is_some_and(|a| a > 0);
|
||||
let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && 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() };
|
||||
@@ -564,14 +557,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 || lab {
|
||||
if invert || palette.is_some() || all_samples {
|
||||
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(if lab && !all_samples { 2 } else { 1 }, len)));
|
||||
patches.push((e.value_at, encode_u(1, len)));
|
||||
}
|
||||
|
||||
let tl = {
|
||||
@@ -592,7 +585,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 == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples))
|
||||
(if photometric == 2 { ChannelLayout::Rgba } else { ChannelLayout::CmykA }, usize::from(num_samples))
|
||||
}
|
||||
tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => {
|
||||
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
|
||||
@@ -603,12 +596,6 @@ 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);
|
||||
@@ -689,9 +676,6 @@ 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 });
|
||||
@@ -699,35 +683,6 @@ 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.
|
||||
@@ -1066,11 +1021,6 @@ 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]>,
|
||||
@@ -1169,20 +1119,6 @@ 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,8 +37,7 @@ pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecErr
|
||||
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, 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();
|
||||
@@ -46,14 +45,9 @@ fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result<impl ImageDec
|
||||
il.max_image_height = Some(limits.max_height);
|
||||
il.max_alloc = Some(limits.max_alloc);
|
||||
reader.limits(il);
|
||||
let dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
|
||||
let mut 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))?;
|
||||
@@ -66,18 +60,6 @@ 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,10 +17,7 @@ fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
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> {
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, 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();
|
||||
@@ -29,11 +26,6 @@ fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32,
|
||||
// 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)?;
|
||||
@@ -49,13 +41,6 @@ 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,10 +30,6 @@ 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.
|
||||
@@ -88,7 +84,6 @@ 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"),
|
||||
@@ -134,8 +129,8 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
|
||||
if layouts.contains(&cand) {
|
||||
return cand;
|
||||
}
|
||||
// CMYK and Lab fall back to RGB; gray expands to RGB.
|
||||
if cand.is_cmyk() || cand.is_lab() || cand.is_gray() {
|
||||
// CMYK falls back to RGB; gray expands to RGB.
|
||||
if cand.is_cmyk() || cand.is_gray() {
|
||||
cand = if cand.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
}
|
||||
if layouts.contains(&cand) {
|
||||
@@ -163,8 +158,6 @@ 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) },
|
||||
}
|
||||
}
|
||||
@@ -202,8 +195,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
if lossy_depth {
|
||||
w.push(W::DepthReduced { from: ss, to: p.sample });
|
||||
}
|
||||
// FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too.
|
||||
if ss.is_float() && (!p.sample.is_float() || format == Format::Fits) && image.has_out_of_range() {
|
||||
if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() {
|
||||
w.push(W::RangeClipped);
|
||||
}
|
||||
|
||||
@@ -217,9 +209,6 @@ 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,8 +33,6 @@ 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.
|
||||
@@ -80,11 +78,6 @@ 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 \
|
||||
@@ -99,12 +92,10 @@ 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; 20] = [
|
||||
pub const ALL: [Format; 19] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
@@ -124,7 +115,6 @@ impl Format {
|
||||
Format::Heif,
|
||||
Format::Fits,
|
||||
Format::Dpx,
|
||||
Format::Cineon,
|
||||
];
|
||||
|
||||
pub fn caps(self) -> FormatCaps {
|
||||
@@ -158,7 +148,6 @@ impl Format {
|
||||
Format::Heif => "HEIF",
|
||||
Format::Fits => "FITS",
|
||||
Format::Dpx => "DPX",
|
||||
Format::Cineon => "Cineon",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +174,6 @@ impl Format {
|
||||
Format::Heif => &["heic", "heif", "hif"],
|
||||
Format::Fits => &["fits", "fit", "fts"],
|
||||
Format::Dpx => &["dpx"],
|
||||
Format::Cineon => &["cin"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +198,6 @@ impl Format {
|
||||
Format::Heif => "image/heif",
|
||||
Format::Fits => "image/fits",
|
||||
Format::Dpx => "image/x-dpx",
|
||||
Format::Cineon => "image/x-cin",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +233,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: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: 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::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,
|
||||
@@ -266,7 +253,6 @@ 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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,9 +307,6 @@ 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,31 +21,19 @@ 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; 8] = [
|
||||
ChannelLayout::Gray,
|
||||
ChannelLayout::GrayA,
|
||||
ChannelLayout::Rgb,
|
||||
ChannelLayout::Rgba,
|
||||
ChannelLayout::Cmyk,
|
||||
ChannelLayout::CmykA,
|
||||
ChannelLayout::Lab,
|
||||
ChannelLayout::LabA,
|
||||
];
|
||||
pub const ALL: [ChannelLayout; 6] =
|
||||
[ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA];
|
||||
|
||||
/// Total number of channels including alpha.
|
||||
pub const fn channels(self) -> usize {
|
||||
match self {
|
||||
ChannelLayout::Gray => 1,
|
||||
ChannelLayout::GrayA => 2,
|
||||
ChannelLayout::Rgb | ChannelLayout::Lab => 3,
|
||||
ChannelLayout::Rgba | ChannelLayout::Cmyk | ChannelLayout::LabA => 4,
|
||||
ChannelLayout::Rgb => 3,
|
||||
ChannelLayout::Rgba | ChannelLayout::Cmyk => 4,
|
||||
ChannelLayout::CmykA => 5,
|
||||
}
|
||||
}
|
||||
@@ -56,7 +44,7 @@ impl ChannelLayout {
|
||||
}
|
||||
|
||||
pub const fn has_alpha(self) -> bool {
|
||||
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA | ChannelLayout::LabA)
|
||||
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA)
|
||||
}
|
||||
|
||||
pub const fn is_gray(self) -> bool {
|
||||
@@ -71,17 +59,12 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,13 +74,12 @@ 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_lab() && other.is_lab())
|
||||
(self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,7 +723,6 @@ 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 => {
|
||||
@@ -749,7 +730,6 @@ 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() {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
//! 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,7 +19,6 @@ pub mod exif_color;
|
||||
mod fidelity;
|
||||
mod format;
|
||||
mod image;
|
||||
mod lab;
|
||||
mod options;
|
||||
pub mod orientation;
|
||||
pub mod resolution;
|
||||
@@ -80,7 +79,6 @@ 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),
|
||||
@@ -99,33 +97,11 @@ 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)
|
||||
@@ -255,7 +231,6 @@ 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,23 +118,6 @@ 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());
|
||||
@@ -233,10 +216,6 @@ fn gen_deep_tables(
|
||||
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() {
|
||||
@@ -258,6 +237,7 @@ fn gen_deep_tables(
|
||||
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));
|
||||
}
|
||||
@@ -628,136 +608,6 @@ 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,17 +187,6 @@ 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,13 +21,7 @@ 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] {
|
||||
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:?}");
|
||||
}
|
||||
assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,12 +115,6 @@ 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:?}");
|
||||
@@ -184,7 +172,6 @@ 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,
|
||||
@@ -216,8 +203,6 @@ 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,7 +236,6 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,24 +554,3 @@ 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,14 +196,6 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
//! 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]);
|
||||
}
|
||||
+139
-664
@@ -79,29 +79,37 @@ 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 } => 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::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::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).
|
||||
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))
|
||||
}
|
||||
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.
|
||||
// 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)))
|
||||
// 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)))
|
||||
}
|
||||
Adjustment::Exposure { exposure, offset, gamma } => {
|
||||
// In linear light: (lin·2^exposure + offset)^(1/gamma), then back
|
||||
@@ -167,17 +175,6 @@ 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])] }
|
||||
@@ -187,39 +184,27 @@ 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| {
|
||||
// 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 = 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 };
|
||||
// 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],
|
||||
}
|
||||
f.map(|v| v.clamp(0.0, 1.0))
|
||||
})
|
||||
}
|
||||
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;
|
||||
}
|
||||
// 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]]);
|
||||
let mut t = rgb_to_gray([p[0], p[1], p[2]]);
|
||||
if *reverse {
|
||||
t = 1.0 - t;
|
||||
}
|
||||
@@ -254,55 +239,13 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 - 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,
|
||||
}
|
||||
move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab
|
||||
@@ -313,25 +256,11 @@ 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};
|
||||
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)
|
||||
});
|
||||
}
|
||||
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)
|
||||
});
|
||||
} else {
|
||||
apply_depth(adj, buf, transfer, depth);
|
||||
}
|
||||
@@ -353,55 +282,13 @@ 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), 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.
|
||||
/// 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.
|
||||
pub fn cmyk_channel_adjustment(adj: &Adjustment) -> bool {
|
||||
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)
|
||||
stored_channel_adjustment(adj)
|
||||
}
|
||||
|
||||
/// Whether [`stored_channels`] evaluates `adj`: Invert, Posterize and Threshold.
|
||||
@@ -411,10 +298,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). 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]) {
|
||||
/// 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) {
|
||||
let lab = mode == ColorMode::Lab;
|
||||
match adj {
|
||||
Adjustment::Invert => {
|
||||
@@ -423,10 +310,7 @@ 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);
|
||||
}
|
||||
}
|
||||
// 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::Posterize { levels } => ch.iter_mut().for_each(|v| *v = posterize(*v, *levels)),
|
||||
Adjustment::Threshold { level } => {
|
||||
// On the 8-bit grid, like the RGB threshold.
|
||||
let t = (level * 255.0).round();
|
||||
@@ -434,9 +318,7 @@ 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 {
|
||||
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 }]
|
||||
[0.0, 0.0, 0.0, if (gray * 255.0).round() < t { 1.0 } else { 0.0 }]
|
||||
};
|
||||
ch.iter_mut().zip(out).for_each(|(v, o)| *v = o);
|
||||
}
|
||||
@@ -444,14 +326,6 @@ 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] {
|
||||
@@ -464,49 +338,6 @@ 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>) {
|
||||
@@ -514,7 +345,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);
|
||||
stored_channels(adj, ColorMode::Lab, depth, &mut n, 0.0);
|
||||
lab_denorm(n)
|
||||
}),
|
||||
Adjustment::Threshold { level } => {
|
||||
@@ -524,11 +355,7 @@ 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 modern = (!*legacy).then(|| modern_lut(*brightness, *contrast));
|
||||
let curve = |v: f32| match &modern {
|
||||
Some(table) => lut(table, v),
|
||||
None => legacy_curve(v),
|
||||
};
|
||||
let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) };
|
||||
// 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| {
|
||||
@@ -764,63 +591,61 @@ 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 (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.
|
||||
/// 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).
|
||||
///
|
||||
/// 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.
|
||||
/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one
|
||||
/// channel: within 1 level).
|
||||
pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 {
|
||||
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;
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
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)
|
||||
v
|
||||
}
|
||||
|
||||
/// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans,
|
||||
@@ -998,118 +823,20 @@ fn hsv_hue(c: [f32; 3], mx: f32, mn: f32) -> f32 {
|
||||
h * 60.0
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// 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).
|
||||
pub fn modern_brightness(v: f32, brightness: f32) -> f32 {
|
||||
if brightness == 0.0 || !brightness.is_finite() {
|
||||
if brightness == 0.0 {
|
||||
return v;
|
||||
}
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
/// Modern Contrast curve (one channel, `contrast` in [-50, 100]). A symmetric cubic-Hermite S-curve
|
||||
@@ -1127,8 +854,6 @@ 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();
|
||||
@@ -1136,75 +861,44 @@ 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 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.
|
||||
/// 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.
|
||||
pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
|
||||
let range = (ch.in_white - ch.in_black).max(1e-6);
|
||||
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 {
|
||||
let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) {
|
||||
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 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)
|
||||
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)
|
||||
}
|
||||
|
||||
/// [`levels`] in a 32-bit document. Neither the input is clipped to the input range nor the result
|
||||
@@ -1484,41 +1178,6 @@ 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];
|
||||
@@ -1543,48 +1202,6 @@ 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
|
||||
@@ -1637,78 +1254,6 @@ 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.
|
||||
@@ -1980,18 +1525,6 @@ 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])];
|
||||
@@ -2067,64 +1600,6 @@ 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));
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
//! Interior effects are painted relative to the layer's shape and then take the shape's alpha
|
||||
//! (an overlay recolours a half-transparent edge without adding coverage). The outside parts
|
||||
//! of strokes blend onto the backdrop with their own modes, an upper stroke covering the
|
||||
//! lower ones. Drop shadows are knocked out through see-through fill, and beneath the whole shape
|
||||
//! when the layer's mode lets what's under it show through (see [`shadow_see_through`]).
|
||||
//! lower ones. Drop shadows are knocked out only through see-through fill.
|
||||
//!
|
||||
//! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows /
|
||||
//! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons,
|
||||
@@ -1851,16 +1850,6 @@ fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the layer lets a knocked-out drop shadow show beneath its own shape (Layer Knocks Out
|
||||
/// Drop Shadow), 0 = covered, 1 = knocked out wholly. In a normal mode an opaque layer covers the
|
||||
/// shadow anyway, so only see-through fill knocks it out (and anti-aliased edges are not
|
||||
/// attenuated twice). In any other mode the layer blends with what's beneath it, shadow and all:
|
||||
/// an Exclusion or Difference layer would invert it, Multiply darken by it, Screen show it as
|
||||
/// grey. Photoshop keeps it out from under the shape, so here it's knocked out wholly.
|
||||
pub fn shadow_see_through(layer: &Layer) -> f32 {
|
||||
if matches!(layer.blend, BlendMode::Normal | BlendMode::Dissolve | BlendMode::PassThrough) { 1.0 - layer.fill_opacity.clamp(0.0, 1.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Blend Interior Effects as Group off (Photoshop's default): the layer's mode applies to its
|
||||
/// own pixels only, and the interior effects paint over the blended result. The layer is merged
|
||||
/// into the backdrop before they paint, so an Overlay colour overlay on a black Color-mode layer
|
||||
@@ -1927,10 +1916,10 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
if let Effect::DropShadow(s) = e {
|
||||
let mut m = fx(i, 0);
|
||||
if s.knocks_out {
|
||||
// The layer hides the shadow beneath it where its fill is see-through (at 100 %
|
||||
// fill in a normal mode it covers it anyway; at 0 % the shape shows the bare
|
||||
// backdrop), and wholly in a mode that blends with it ([`shadow_see_through`]).
|
||||
knock_out_exterior(&mut m, &shape, 1.0 - shadow_see_through(layer));
|
||||
// The layer hides the shadow beneath it only where its fill is see-through: at
|
||||
// 100 % fill the layer covers it anyway (and anti-aliased edges are not
|
||||
// attenuated twice), at 0 % the shape shows the bare backdrop.
|
||||
knock_out_exterior(&mut m, &shape, layer.fill_opacity);
|
||||
}
|
||||
paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity);
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+14
-99
@@ -21,12 +21,10 @@ 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;
|
||||
@@ -130,14 +128,6 @@ 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, || {
|
||||
@@ -270,8 +260,7 @@ 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),
|
||||
/// and a Lab one in its L, a and b ([`native_lab::flatten_lab`]).
|
||||
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778).
|
||||
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
|
||||
composite_to_surface(doc, doc.bounds(), fmt, background)
|
||||
}
|
||||
@@ -284,11 +273,6 @@ 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();
|
||||
@@ -342,7 +326,6 @@ 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,
|
||||
);
|
||||
@@ -540,60 +523,6 @@ 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> {
|
||||
@@ -609,7 +538,6 @@ impl<'a> Ctx<'a> {
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
fx_clip: Default::default(),
|
||||
layer_keys: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1362,17 +1290,11 @@ 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), 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(_))));
|
||||
// adjustment inside no longer reaches the layers beneath).
|
||||
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);
|
||||
@@ -1532,12 +1454,11 @@ 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. `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>)) {
|
||||
/// 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) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
fn surface_fp(s: &Surface, pin: &mut dyn FnMut(&std::sync::Arc<photocraft_raster::Tile>)) -> u64 {
|
||||
fn surface_fp(s: &Surface) -> 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.
|
||||
@@ -1545,7 +1466,6 @@ 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))
|
||||
@@ -1566,7 +1486,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, pin);
|
||||
layer_identity(c, h);
|
||||
}
|
||||
format!("{:?}", g.artboard).hash(h);
|
||||
}
|
||||
@@ -1578,14 +1498,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, pin).hash(h);
|
||||
surface_fp(&c.surface).hash(h);
|
||||
}
|
||||
}
|
||||
LayerContent::Adjustment(a) => format!("{a:?}").hash(h),
|
||||
_ => layer.surface().map_or(0, |s| surface_fp(s, pin)).hash(h),
|
||||
_ => layer.surface().map_or(0, surface_fp).hash(h),
|
||||
}
|
||||
if let Some(m) = &layer.mask {
|
||||
(surface_fp(&m.surface, pin), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h);
|
||||
(surface_fp(&m.surface), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h);
|
||||
}
|
||||
if let Some(vm) = &layer.vector_mask {
|
||||
format!("{vm:?}").hash(h);
|
||||
@@ -1600,8 +1520,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
|
||||
|
||||
struct FxEntry {
|
||||
maps: std::sync::Arc<effects::FxMaps>,
|
||||
/// The tiles the key identifies (see [`layer_identity`]): their addresses, not their pixels.
|
||||
_pin: Vec<std::sync::Weak<photocraft_raster::Tile>>,
|
||||
_pin: Layer,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
@@ -1684,7 +1603,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();
|
||||
cx.layer_keys.identity(layer, cx.doc).hash(&mut h);
|
||||
layer_identity(layer, &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();
|
||||
@@ -1720,13 +1639,9 @@ 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 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 bytes = maps.bytes();
|
||||
let maps = std::sync::Arc::new(maps);
|
||||
if slot.set(FxEntry { maps: maps.clone(), _pin: pin, bytes }).is_ok() {
|
||||
if slot.set(FxEntry { maps: maps.clone(), _pin: layer.clone(), 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());
|
||||
|
||||
+34
-110
@@ -171,10 +171,9 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// 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).
|
||||
pub fn feather_sigma(px: f32) -> f32 {
|
||||
if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 }
|
||||
}
|
||||
@@ -189,73 +188,37 @@ 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], pass: BoxPass) {
|
||||
fn blur_line(line: &mut [f32], scratch: &mut [f32], r: usize) {
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(radius) = isize::try_from(pass.r) else { return };
|
||||
let Ok(radius) = isize::try_from(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 * 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);
|
||||
let n = (2 * r + 1) as f32;
|
||||
for (i, dst) in line.iter_mut().enumerate() {
|
||||
*dst = acc / n;
|
||||
let i = i as isize;
|
||||
let (above, leaving) = (get(i + radius + 1), get(i - radius));
|
||||
*dst = (acc + pass.end * (below + above)) / n;
|
||||
acc += above - leaving;
|
||||
below = leaving;
|
||||
acc += get(i + radius + 1) - get(i - radius);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, pass: BoxPass) {
|
||||
fn blur_rows_chunk(v: &mut [f32], w: usize, r: usize) {
|
||||
let mut scratch = vec![0.0; w];
|
||||
for row in v.chunks_mut(w) {
|
||||
for _ in 0..3 {
|
||||
blur_line(row, &mut scratch, pass);
|
||||
blur_line(row, &mut scratch, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) {
|
||||
fn blur_rows(v: &mut [f32], w: usize, r: usize) {
|
||||
if w == 0 || v.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -266,14 +229,14 @@ fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) {
|
||||
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, pass);
|
||||
blur_rows_chunk(v, w, r);
|
||||
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, pass));
|
||||
scope.spawn(move || blur_rows_chunk(chunk, w, r));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -293,34 +256,35 @@ 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 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.
|
||||
/// 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.
|
||||
fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) {
|
||||
if w == 0 || h == 0 || v.len() != w.saturating_mul(h) {
|
||||
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 {
|
||||
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, pass);
|
||||
gaussian_sequential(v, w, h, r);
|
||||
return;
|
||||
}
|
||||
transposed.resize(v.len(), 0.0);
|
||||
blur_rows(v, w, pass);
|
||||
blur_rows(v, w, r);
|
||||
transpose_into(v, &mut transposed, w, h);
|
||||
blur_rows(&mut transposed, h, pass);
|
||||
blur_rows(&mut transposed, h, r);
|
||||
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, pass: BoxPass) {
|
||||
fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, r: usize) {
|
||||
let mut line = Vec::new();
|
||||
let mut scratch = Vec::new();
|
||||
let len = w.max(h);
|
||||
@@ -336,7 +300,7 @@ fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) {
|
||||
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], pass);
|
||||
blur_line(&mut line[..line_len], &mut scratch[..line_len], r);
|
||||
}
|
||||
for i in 0..line_len {
|
||||
if axis {
|
||||
@@ -729,55 +693,14 @@ 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 pass = BoxPass::for_sigma(sigma).unwrap();
|
||||
gaussian_sequential(&mut sequential, w, h, pass);
|
||||
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);
|
||||
gaussian(&mut parallel, w, h, sigma);
|
||||
assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic");
|
||||
}
|
||||
@@ -788,11 +711,12 @@ mod tests {
|
||||
use std::time::Instant;
|
||||
|
||||
let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0);
|
||||
let pass = BoxPass::for_sigma(sigma).unwrap();
|
||||
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
|
||||
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
|
||||
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, pass);
|
||||
gaussian_sequential(&mut sequential, w, h, r);
|
||||
let sequential_time = start.elapsed();
|
||||
let mut parallel = source;
|
||||
let start = Instant::now();
|
||||
|
||||
+44
-252
@@ -1,255 +1,85 @@
|
||||
//! Mode-native compositing of plain CMYK layer stacks (#3778).
|
||||
//! Mode-native flattening 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. 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.
|
||||
//! values and an opaque layer keeps them exactly.
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::adjust::ToneSpace;
|
||||
use photocraft_doc::{Adjustment, Document, Layer, LayerContent, LayerMask};
|
||||
use photocraft_color::{ColorMode, PixelFormat};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::{Buffer, adjust};
|
||||
|
||||
/// Process channels of a CMYK pixel.
|
||||
const INKS: usize = 4;
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
&& !layer.clipped
|
||||
&& !crate::masks::has_feather(layer)
|
||||
&& layer.mask.as_ref().is_none_or(|m| !m.enabled)
|
||||
&& 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()
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&& s.format().mode == ColorMode::Cmyk;
|
||||
simple.then_some(s)
|
||||
}
|
||||
|
||||
/// 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 fmt.mode != ColorMode::Cmyk {
|
||||
if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk || doc.layers.iter().any(|l| l.clipped) {
|
||||
return None;
|
||||
}
|
||||
let stack = InkStack::new(doc)?;
|
||||
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 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 mv, mut data) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
let (mut acc, mut src, mut data) = (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);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
data.clear();
|
||||
data.resize(acc.len() * n, 0.0);
|
||||
for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) {
|
||||
@@ -269,41 +99,3 @@ 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)
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
//! 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, SmartObject};
|
||||
use photocraft_geom::{Affine, Rect};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k).
|
||||
@@ -60,30 +60,11 @@ 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) => shrink_smart(so, k),
|
||||
LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, 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.
|
||||
@@ -185,33 +166,6 @@ 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);
|
||||
|
||||
+10
-586
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace};
|
||||
use photocraft_doc::adjust::{CurvePoint, LevelsChannel};
|
||||
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerMask};
|
||||
use photocraft_geom::Size;
|
||||
|
||||
@@ -606,142 +606,6 @@ 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);
|
||||
@@ -1609,36 +1473,6 @@ fn shadow_knockout_needs_see_through_fill() {
|
||||
assert!(close4(on, px(&base, 10, 20)), "{on:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_knockout_beneath_a_layer_in_a_blending_mode() {
|
||||
// Layer Knocks Out Drop Shadow keeps the shadow out from under the layer's shape. In Normal
|
||||
// mode an opaque layer covers it anyway; in a mode that blends with what's beneath, the shadow
|
||||
// would show through: under a white Exclusion square on white, it read as white (white over
|
||||
// the black shadow) instead of black (white over white), so a logo's Exclusion bar with a
|
||||
// drop shadow came out blank. A red Multiply square came out black instead of red.
|
||||
for (blend, colour, inside, depth) in [
|
||||
(photocraft_color::BlendMode::Exclusion, [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
|
||||
(photocraft_color::BlendMode::Multiply, [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|c| SampleType::ALL.map(|depth| (c, depth)))
|
||||
.map(|((b, c, i), depth)| (b, c, i, depth))
|
||||
{
|
||||
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, depth);
|
||||
d.layers.push(solid_layer("bg", Rect::new(0, 0, 40, 40), [1.0; 4]));
|
||||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), colour);
|
||||
l.blend = blend;
|
||||
l.effects.items = vec![Effect::DropShadow(shadow(0.0, 90.0))];
|
||||
d.layers.push(l);
|
||||
assert!(close4(px(&d, 20, 20), inside), "{blend:?} {depth:?}: {:?}", px(&d, 20, 20));
|
||||
// Not knocked out, the shadow shows through the blend, as in Photoshop with the box unticked.
|
||||
if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] {
|
||||
s.knocks_out = false;
|
||||
}
|
||||
assert!(!close4(px(&d, 20, 20), inside), "{blend:?} {depth:?} without knockout: {:?}", px(&d, 20, 20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_pattern_overlay_anchors_at_the_effects_reference_point() {
|
||||
use photocraft_doc::pattern::Pattern;
|
||||
@@ -1864,43 +1698,6 @@ 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])];
|
||||
@@ -1909,48 +1706,8 @@ 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();
|
||||
// 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}");
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
// Endpoints and the zero slider are exact.
|
||||
@@ -1961,7 +1718,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 shadow toe (initial slope 2^gamma).
|
||||
// `levels()` matches the real app, including the gamma>1 soft 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 };
|
||||
@@ -1977,8 +1734,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 ends before white, so white
|
||||
// stays exactly 1.0.)
|
||||
// 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.)
|
||||
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);
|
||||
@@ -2019,86 +1776,6 @@ 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.
|
||||
@@ -2617,10 +2294,9 @@ 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), and Photoshop's
|
||||
// results there are unclipped: above 1 and below 0.
|
||||
// looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply).
|
||||
let warming32 = [0.966_445_6, 0.761_806_3, 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])] {
|
||||
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])] {
|
||||
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:?}");
|
||||
}
|
||||
@@ -2824,95 +2500,15 @@ 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 (0 at 16 bits, whose middle level is 128), a neutral grey).
|
||||
// step: L ≈ 50 and a, b ≈ -1, 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_in(v, 3, Some(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 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);
|
||||
@@ -2968,75 +2564,6 @@ 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);
|
||||
@@ -3111,106 +2638,3 @@ 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,17 +45,11 @@ 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, color: String::new() }
|
||||
Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None }
|
||||
}
|
||||
|
||||
/// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved.
|
||||
@@ -241,7 +235,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, "color": action.color}))
|
||||
Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set}))
|
||||
}
|
||||
|
||||
fn record(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -559,25 +553,6 @@ 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 {
|
||||
@@ -605,7 +580,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, 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)"##,
|
||||
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)"##,
|
||||
enabled: always,
|
||||
run: get,
|
||||
journal: false,
|
||||
@@ -670,16 +645,6 @@ 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -992,109 +957,6 @@ 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();
|
||||
@@ -1446,24 +1308,4 @@ 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");
|
||||
}
|
||||
}
|
||||
@@ -353,6 +353,7 @@ fn invert_with_the_mask_targeted_inverts_the_mask() {
|
||||
let mut s = session(depth, "rgb");
|
||||
s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap();
|
||||
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let (pixels, off_canvas) = (rgba(&s, 3, 3), mask_at(&s, -500, 3));
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("image.adjustments.invert", json!({"target": "mask"})).unwrap();
|
||||
@@ -371,31 +372,6 @@ fn invert_with_the_mask_targeted_inverts_the_mask() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop: a mask made from the selection (Reveal Selection, the Layers panel's mask button)
|
||||
/// takes the selection with it, as Hide Selection does. So ⌘I on the new mask right away inverts
|
||||
/// all of it; with the selection kept it inverted only the inside, leaving the mask all black.
|
||||
/// Undo brings the selection back with the mask gone, in one step.
|
||||
#[test]
|
||||
fn reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth, "rgb");
|
||||
s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap();
|
||||
let selection = s.active().unwrap().doc.selection.clone();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.selection.is_none(), "depth {depth}: the selection became the mask");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one step");
|
||||
assert_eq!((mask_at(&s, 3, 3), mask_at(&s, 30, 3)), (0.0, 1.0));
|
||||
s.execute("image.adjustments.invert", json!({"target": "mask"})).unwrap();
|
||||
assert_eq!((mask_at(&s, 3, 3), mask_at(&s, 30, 3)), (1.0, 0.0), "depth {depth}: inside and outside both flip");
|
||||
s.undo();
|
||||
s.undo();
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.selection, selection, "undo restores the selection");
|
||||
assert!(st.doc.layer(st.active_layer.unwrap()).unwrap().mask.is_none(), "and removes the mask");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Image › Adjustments as smart filters (FILE-215-4) ----------
|
||||
|
||||
/// A painted pixel layer converted to a smart object, over the painted Background.
|
||||
@@ -516,6 +492,7 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() {
|
||||
s.execute("layer.delete", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 24, "y": 0, "width": 24, "height": 32})).unwrap();
|
||||
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let pixels = rgba(&s, 23, 3);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3, "target": "mask"})).unwrap();
|
||||
assert!(mask_at(&s, 23, 3) > 0.0 && mask_at(&s, 23, 3) < 1.0, "the mask's edge is blurred");
|
||||
@@ -527,106 +504,6 @@ 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.
|
||||
@@ -661,53 +538,3 @@ 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,7 +381,7 @@ mod tests {
|
||||
let m = r["aligned"][0]["matrix"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
|
||||
assert!((m[2] + 6.0).abs() < 1.0 && (m[5] + 5.0).abs() < 1.0, "{r}");
|
||||
assert!(s.execute("edit.autoAlignLayers", json!({"projection": "sideways"})).is_err());
|
||||
assert!(s.execute("edit.autoAlignLayers", json!({"reference": u64::MAX})).is_err());
|
||||
assert!(s.execute("edit.autoAlignLayers", json!({"reference": 9999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -177,12 +177,6 @@ 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.
|
||||
@@ -193,37 +187,23 @@ 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;
|
||||
let result = match r {
|
||||
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.trim_history();
|
||||
st.history.trim(&st.doc);
|
||||
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,69 +11,6 @@ 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,26 +983,6 @@ 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") {
|
||||
@@ -1137,21 +1117,6 @@ 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");
|
||||
@@ -1911,11 +1876,6 @@ 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());
|
||||
@@ -1937,10 +1897,6 @@ 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" => {
|
||||
@@ -2740,7 +2696,6 @@ const EVENTS: &[Event] = &[
|
||||
"layer.new.layer",
|
||||
"layer.new.group",
|
||||
"layer.groupLayers",
|
||||
"layer.duplicate",
|
||||
"layer.new.layerViaCopy",
|
||||
"layer.new.layerViaCut",
|
||||
"layer.newFillLayer.solidColor",
|
||||
|
||||
@@ -212,12 +212,6 @@ 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"})),
|
||||
@@ -454,17 +448,6 @@ 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"),
|
||||
@@ -474,8 +457,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", PHOTOSHOP_STEP]);
|
||||
assert_eq!(imp.unsupported.len(), 5);
|
||||
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]);
|
||||
assert_eq!(imp.unsupported.len(), 4);
|
||||
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
|
||||
}
|
||||
|
||||
|
||||
@@ -24,22 +24,6 @@ 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 {
|
||||
@@ -143,12 +127,6 @@ 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)?,
|
||||
|
||||
@@ -50,31 +50,10 @@ fn browse_runs_a_script_file() {
|
||||
assert_eq!((d.size.width, d.size.height), (20, 30));
|
||||
assert!(s.execute("file.scripts.browse", json!({"script": "no.such.command"})).is_err());
|
||||
// A failing step stops the script and is reported.
|
||||
let r = s.execute("file.scripts.browse", json!({"steps": [["layer.delete", {"layer": u64::MAX}], ["image.imageRotation.90cw", {}]]})).unwrap();
|
||||
let r = s.execute("file.scripts.browse", json!({"steps": [["layer.delete", {"layer": 999_999}], ["image.imageRotation.90cw", {}]]})).unwrap();
|
||||
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();
|
||||
@@ -369,7 +348,7 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() {
|
||||
});
|
||||
s.actions.list.push(Action {
|
||||
name: "Bad".into(),
|
||||
steps: vec![("layer.delete".into(), json!({"layer": u64::MAX}))],
|
||||
steps: vec![("layer.delete".into(), json!({"layer": 999_999}))],
|
||||
disabled: Default::default(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -144,32 +144,6 @@ mod tests {
|
||||
(s, [bg, a, b])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_fill_or_a_blend_mode_turn_the_background_into_a_layer() {
|
||||
// Like Photoshop: the Background takes none of them, so it becomes "Layer 0" first.
|
||||
for (key, value) in [("opacity", json!(0.5)), ("fill", json!(0.5)), ("blend", json!("Multiply"))] {
|
||||
let (mut s, [bg, ..]) = with_background();
|
||||
s.execute("layer.setProps", json!({"layer": bg.0, key: value})).unwrap();
|
||||
let l = doc(&s).layer(bg).unwrap();
|
||||
assert!(!doc(&s).is_background(bg) && l.name == "Layer 0", "{key}");
|
||||
assert!(l.opacity < 1.0 || l.fill_opacity < 1.0 || l.blend == photocraft_color::BlendMode::Multiply, "{key}: applied");
|
||||
}
|
||||
// Values that leave its look alone keep it the Background.
|
||||
let (mut s, [bg, ..]) = with_background();
|
||||
s.execute("layer.setProps", json!({"layer": bg.0, "opacity": 1.0, "fill": 1.0, "blend": "Normal", "visible": false})).unwrap();
|
||||
assert!(doc(&s).is_background(bg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_and_mask_output_to_a_layer_mask_turns_the_background_into_a_layer() {
|
||||
let (mut s, [bg, ..]) = with_background();
|
||||
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 6, "height": 6})).unwrap();
|
||||
s.execute("select.refineEdge", json!({"output": "layerMask"})).unwrap();
|
||||
let l = doc(&s).layer(bg).unwrap();
|
||||
assert!(l.mask.is_some() && !doc(&s).is_background(bg) && l.name == "Layer 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn makes_the_layer_the_background_at_the_bottom() {
|
||||
for depth in [8, 16, 32] {
|
||||
@@ -233,7 +207,7 @@ mod tests {
|
||||
assert!(s.execute(CMD, json!({"layer": bg.0})).is_err());
|
||||
// Bad params.
|
||||
let (mut s, [_, a, _]) = session(8);
|
||||
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": u64::MAX}), json!({"layer": [a.0]})] {
|
||||
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(doc(&s).background().is_none());
|
||||
|
||||
@@ -143,11 +143,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -193,7 +188,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.resize_with_dual_brush(v.max(0.5));
|
||||
b.size = v.max(0.5);
|
||||
}
|
||||
if let Some(v) = num(p, "hardness") {
|
||||
b.hardness = v.clamp(0.0, 1.0);
|
||||
@@ -873,24 +868,11 @@ 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 at(x, y) > 1e-4 {
|
||||
if v[y * w + x] > 1e-4 {
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x + 1);
|
||||
@@ -899,19 +881,23 @@ pub(crate) fn sampled_brush(cmd: &str, v: &[f32], w: usize, h: usize) -> Result<
|
||||
}
|
||||
}
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Err(bad(cmd, "the pixels contain no paint (all white or transparent)"));
|
||||
return Err(bad(cmd, "the selected 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!("too large for a brush tip ({tw}×{th}; max {MAX_TIP} px)")));
|
||||
return Err(bad(cmd, format!("selection 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((x0..x1).map(|x| at(x, y)));
|
||||
data.extend_from_slice(&v[y * w + x0..y * w + x1]);
|
||||
}
|
||||
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() };
|
||||
Ok((brush, tw, th))
|
||||
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 }))
|
||||
}
|
||||
|
||||
pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -349,38 +349,6 @@ 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);
|
||||
@@ -942,23 +910,6 @@ 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);
|
||||
|
||||
@@ -59,16 +59,15 @@ pub(crate) fn copy(s: &mut Session) -> Result<Option<Value>> {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
}
|
||||
let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, true);
|
||||
let values = crate::allocation::read_region(src, area, "copying a channel")?;
|
||||
let cover = sel.map(|m| crate::allocation::read_region(m, area, "copying a channel selection").map(|v| (v, m.format().channels().max(1)))).transpose()?;
|
||||
let len = (values.len() / n).checked_mul(2).ok_or_else(|| EngineError::Other("the channel is too large to copy".into()))?;
|
||||
let mut px = crate::allocation::capacity(len, "copying a channel")?;
|
||||
let values = src.read_region(area);
|
||||
let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1)));
|
||||
let mut px = Vec::with_capacity(values.len() / n * 2);
|
||||
for (i, p) in values.chunks_exact(n).enumerate() {
|
||||
px.push(p.get(k).copied().unwrap_or(0.0));
|
||||
px.push(cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)));
|
||||
}
|
||||
let mut surface = Surface::new(fmt);
|
||||
surface.try_write_region(area, &px)?;
|
||||
surface.write_region(area, &px);
|
||||
surface.prune();
|
||||
let bounds = surface.content_bounds();
|
||||
if bounds.is_empty() {
|
||||
@@ -96,13 +95,10 @@ 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 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")?;
|
||||
let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
|
||||
src.read_rgba_into(area, &mut rgba);
|
||||
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")?;
|
||||
let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1)));
|
||||
let mut px = surf.read_region(area);
|
||||
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]]);
|
||||
@@ -110,7 +106,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.try_write_region(area, &px)?;
|
||||
surf.write_region(area, &px);
|
||||
surf.prune();
|
||||
}
|
||||
doc.selection = None;
|
||||
|
||||
@@ -163,41 +163,3 @@ fn clearing_a_channel_reports_allocation_failure_without_editing() {
|
||||
assert_eq!(s.active().unwrap().history.entries(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[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] {
|
||||
let mut s = session(depth);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "red"})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let clip = s.clipboard.as_ref().unwrap().surface.clone();
|
||||
let history = s.active().unwrap().history.entries();
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = copy(&mut s);
|
||||
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);
|
||||
assert_eq!(s.clipboard.as_ref().unwrap().surface, clip);
|
||||
}
|
||||
}
|
||||
@@ -182,82 +182,6 @@ 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,51 +867,3 @@ 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)));
|
||||
}
|
||||
Loaded 100 of 442 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user