Integration: merge main (#6966)

This commit is contained in:
David Mann committed 2026-10-22 21:28:41 +02:00
commit 1e3323f64c
1095 files changed
+105697 -8527

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+29 -16
View File
@@ -37,7 +37,8 @@ concurrency:
jobs:
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
# the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
# macOS is tested only by release.yml.
platforms:
timeout-minutes: 10
@@ -78,7 +79,7 @@ jobs:
else
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "legs=$all" >> "$GITHUB_OUTPUT"
else
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
@@ -113,7 +114,7 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
@@ -122,7 +123,7 @@ jobs:
# A failing test still saves: the cache holds compiled dependencies, not test results.
# The Windows legs share one key (same dependencies); the integration leg saves it,
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
@@ -193,6 +194,16 @@ jobs:
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
shell: bash
# Every command against adversarial params must return Err, never panic or hang (AGENTS.md
# Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as
# Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency
# features and recompile the whole graph. The `ci` profile terminates it if it hangs.
- name: Panic hunt
if: runner.os == 'Linux'
run: >-
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
--run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)'
- name: Doctests
if: matrix.part == 'all' || matrix.part == 'unit'
run: cargo test --workspace --locked --features heif --doc
@@ -241,9 +252,9 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: test
save-if: false
@@ -296,8 +307,8 @@ jobs:
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: corpus
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -318,10 +329,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -341,17 +352,17 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: ci-web
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
curl --proto '=https' -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
@@ -409,15 +420,17 @@ jobs:
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: crates/${{ matrix.target.crate }}/fuzz
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
- name: Fuzz
working-directory: crates/${{ matrix.target.crate }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
env:
FUZZ_SECONDS: ${{ inputs.fuzz_seconds }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install mdBook
run: cargo install mdbook --version 0.4.52 --locked
- name: Build documentation
+3 -1
View File
@@ -32,5 +32,7 @@ jobs:
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
cargo-build-extra-args: "--features heif"
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
# once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui --exclude photocraft-gpu && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-gpu --locked -- --test-threads=1 && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
run-package-check: true
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+2 -2
View File
@@ -44,9 +44,9 @@ jobs:
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: perf-release
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+5 -4
View File
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
## 2. Workspace map
@@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
crates/
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
psd codecs L0 standalone format crates (no workspace deps; publishable)
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
@@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
@@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## 4. Picking work
@@ -107,7 +108,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop- and GIMP-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
+9 -2
View File
@@ -25,24 +25,29 @@ next to them.
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese 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/tr.tsv` | Turkish 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/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) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian 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`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.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`, `align-edge-*.svg` and `distribute-*.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/slice-select.svg` | Slice Select 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 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `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/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection 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) |
@@ -109,11 +114,13 @@ next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `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/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files |
| `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 |
Generated
+114 -52
View File
@@ -849,7 +849,7 @@ dependencies = [
"maybe-owned",
"rustix 1.1.5",
"rustix-linux-procfs",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"winx",
]
@@ -1222,6 +1222,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1229,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
"roxmltree",
"roxmltree 0.20.0",
"tempfile",
"winprint",
"winsafe",
@@ -1891,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1929,6 +1934,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exacl"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae"
dependencies = [
"bitflags 2.13.2",
"log",
"scopeguard",
"uuid",
]
[[package]]
name = "exr"
version = "1.74.2"
@@ -2099,7 +2116,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
"roxmltree 0.20.0",
]
[[package]]
@@ -2124,9 +2141,17 @@ dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
"read-fonts",
"roxmltree 0.21.1",
"smallvec",
"windows",
"windows-core",
"yeslogic-fontconfig-sys",
]
[[package]]
@@ -3028,7 +3053,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3690,7 +3715,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4436,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4613,10 +4638,11 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"arboard",
"async-io",
"chrono",
"core-graphics2",
"dispatch2",
"eframe",
@@ -4646,6 +4672,7 @@ dependencies = [
"rfd",
"ron",
"serde_json",
"tempfile",
"winit",
"winresource",
"winsafe",
@@ -4657,7 +4684,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4667,7 +4694,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4680,7 +4707,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4690,9 +4717,10 @@ dependencies = [
"photocraft-doc",
"photocraft-engine",
"photocraft-format",
"photocraft-geom",
"photocraft-io",
"photocraft-raw",
"rmcp",
"rustix 1.1.5",
"schemars",
"serde",
"serde_json",
@@ -4702,13 +4730,14 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
"photocraft-engine",
"photocraft-format",
"photocraft-io",
"photocraft-raw",
"serde_json",
"tokio",
"winresource",
@@ -4716,7 +4745,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"moxcms",
"rayon",
@@ -4724,7 +4753,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"exr",
"flate2",
@@ -4745,7 +4774,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4753,7 +4782,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4768,7 +4797,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4779,9 +4808,10 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"chrono",
"craft-print",
"exr",
"flate2",
@@ -4795,6 +4825,7 @@ dependencies = [
"photocraft-format",
"photocraft-geom",
"photocraft-io",
"photocraft-ipp",
"photocraft-ops",
"photocraft-paint",
"photocraft-plugins",
@@ -4804,20 +4835,23 @@ dependencies = [
"photocraft-text",
"photocraft-vector",
"rayon",
"roxmltree",
"roxmltree 0.20.0",
"same-file",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"wat",
"winsafe",
]
[[package]]
name = "photocraft-format"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"crc32fast",
"exacl",
"flate2",
"libc",
"photocraft-codecs",
@@ -4831,21 +4865,23 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"winsafe",
]
[[package]]
name = "photocraft-geom"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"half",
"log",
"photocraft-cms",
"photocraft-color",
"photocraft-compose",
"photocraft-doc",
@@ -4859,16 +4895,17 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"craft-docpath",
"crc32fast",
"flate2",
"photocraft-affinity",
@@ -4888,21 +4925,25 @@ dependencies = [
"photocraft-vector",
"proptest",
"resvg",
"roxmltree",
"roxmltree 0.20.0",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ipp"
version = "0.12.0"
[[package]]
name = "photocraft-ops"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4913,7 +4954,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4928,7 +4969,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4939,17 +4980,18 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
"proptest",
"rayon",
"serde",
]
[[package]]
name = "photocraft-raw"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -4964,7 +5006,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4976,18 +5018,15 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"memory-stats",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"serde_json",
]
[[package]]
name = "photocraft-text"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"parley",
@@ -4996,12 +5035,13 @@ dependencies = [
"photocraft-geom",
"photocraft-psd",
"photocraft-raster",
"rayon",
"skrifa",
]
[[package]]
name = "photocraft-ui-egui"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5036,6 +5076,7 @@ dependencies = [
"serde",
"serde_json",
"sys-locale",
"tempfile",
"toml",
"unicode-bidi",
"winsafe",
@@ -5043,7 +5084,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5054,7 +5095,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -5076,7 +5117,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"js-sys",
@@ -5469,7 +5510,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5930,6 +5971,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -5988,7 +6038,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6055,7 +6105,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6693,7 +6743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6920,10 +6970,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7323,7 +7373,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7430,7 +7480,7 @@ dependencies = [
"kurbo 0.11.3",
"log",
"pico-args",
"roxmltree",
"roxmltree 0.20.0",
"rustybuzz",
"simplecss",
"siphasher",
@@ -8083,7 +8133,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8663,8 +8713,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"toml",
@@ -8676,6 +8727,17 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]]
name = "yoke"
version = "0.8.3"
+5 -1
View File
@@ -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.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -36,9 +36,11 @@ photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
photocraft-ipp = { path = "crates/ipp" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -57,6 +59,8 @@ egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
criterion = { version = "0.5", default-features = false }
getrandom = "0.3"
+2 -2
View File
@@ -25,8 +25,8 @@ RUN set -eu; \
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
cd /tmp; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
tar -xzf "$asset" -C /usr/local/bin trunk; \
rm "$asset" "$asset.sha256"; \
+12 -3
View File
@@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png" width="128" height="128" alt="PhotoCraft icon: an engraved nine-tailed fox on a blue tile">
</p>
<h1 align="center">PhotoCraft</h1>
<p align="center">
@@ -20,6 +24,9 @@
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_photocraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/photocraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -199,7 +206,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/xcf.md">GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
@@ -214,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
@@ -275,6 +282,8 @@ flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-a
flatpak run ai.storyteller.photocraft
```
The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md).
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).
@@ -334,7 +343,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
+6 -3
View File
@@ -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-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously 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
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)
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
| Korean | `ko` | 100% | partial |
| Vietnamese | `vi` | 0% | none |
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
## Upcoming
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
@@ -135,7 +136,9 @@ 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-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
+4
View File
@@ -31,6 +31,10 @@ photocraft-codecs = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+8
View File
@@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return Ok(());
}
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
// the print command checks for it first and reports an error instead.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
+135 -11
View File
@@ -15,19 +15,27 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
An <out> that is the <in> file is refused, as the result would replace the original;
--in-place allows it.
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data);
--tiff-compression compresses it (none, the default; lzw; zip).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
--jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized
(Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so
only Gray and CMYK shrink) or progressive.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -69,18 +77,47 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--tiff-layers"],
values: &[
"--new",
"--cmd",
"--params",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--in-place", "--tiff-layers"],
values: &[
"--actions",
"--action",
"--in",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
@@ -219,6 +256,23 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
if let Some(c) = a.get("--tiff-compression") {
o.encode.tiff_compression = match c {
"none" => photocraft_codecs::TiffCompression::None,
"lzw" => photocraft_codecs::TiffCompression::Lzw,
"zip" => photocraft_codecs::TiffCompression::Deflate,
_ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")),
};
}
if let Some(f) = a.get("--jpeg-format") {
o.encode.jpeg_format =
photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?;
}
if let Some(c) = a.get("--dds-compression") {
o.encode.dds.compression =
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
}
o.encode.dds.mipmaps = a.has("--dds-mipmaps");
Ok(o)
}
@@ -239,12 +293,28 @@ fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
.map_err(|error| error.to_string())
}
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
/// against mistakes, it is not a security boundary.
fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R {
if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) {
return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it"));
}
Ok(())
}
fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
refuse_overwriting_input(a, input, output)?;
let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
warn_all(err, &o.warnings);
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
@@ -444,11 +514,32 @@ fn is_input(p: &Path) -> bool {
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
fn strip_shell_steps(action: &mut Action) {
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
if shell_save_command(&id) || shell_view_command(&id) {
continue;
}
if action.disabled.contains(&i) {
disabled.insert(steps.len());
}
if action.dialogs.contains(&i) {
dialogs.insert(steps.len());
}
steps.push((id, p));
}
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
}
fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
set.iter_mut().for_each(strip_shell_steps);
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
@@ -751,4 +842,37 @@ mod batch_action_set_tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Save and view steps recorded inside a batched action set are skipped, also in a called
/// action, and unchecked steps stay unchecked once they are gone (#6860).
#[test]
fn batch_skips_save_and_view_steps_inside_an_action_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
{"name": "Rotate", "steps": [
["file.save", {}],
["image.imageRotation.90cw", {}],
["view.fitOnScreen", {}],
["image.imageRotation.90cw", {}],
["file.saveAs", {}],
], "disabled": [3], "dialogs": [1]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
for action in ["Outer", "Rotate"] {
let (code, stdout, stderr) =
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
assert_eq!(code, 0, "{stdout}{stderr}");
let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() {
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
gif.push(0x3B);
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
// writes, after the decode warning.
let cases = [
("whole.jpg", &jpeg[..], None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
("whole.jpg", &jpeg[..], None, None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
];
for (name, bytes, warning) in cases {
for (name, bytes, warning, export) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
assert_eq!(
err.trim(),
expected.join(
"
"
),
"{name}"
);
}
std::fs::remove_dir_all(d).unwrap();
}
@@ -494,6 +504,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
}
}
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
#[test]
fn dds_converts_both_ways_with_the_compression_asked_for() {
let d = tmp("dds");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (args, fourcc, mips) in [
(&[][..], [0; 4], 1),
(&["--dds-compression", "bc1"][..], *b"DXT1", 1),
(&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
(&["--dds-compression", "bc3"][..], *b"DXT5", 1),
(&["--dds-mipmaps"][..], [0; 4], 4),
] {
let out = d.join("a.dds");
ok(bin().arg("convert").arg(&a).arg(&out).args(args));
let bytes = std::fs::read(&out).unwrap();
let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(&out).arg(&back));
assert!(back.exists(), "{args:?}");
}
for bad in ["bc7", "dxt2", ""] {
let out = d.join("bad.dds");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
@@ -520,6 +563,33 @@ fn tga_bits_choose_the_targa_depth() {
}
}
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
/// doesn't name is refused and nothing is written.
#[test]
fn jpeg_format_chooses_the_scans() {
let d = tmp("jpeg-format");
let a = d.join("a.png");
write_png(&a, 16, 8, 3);
let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]);
for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] {
let out = d.join("a.jpg");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(f) = format {
convert.args(["--jpeg-format", f]);
}
ok(&mut convert);
assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}");
}
for bad in ["Progressive", "1", ""] {
let out = d.join("bad.jpg");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
}
}
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
@@ -566,6 +636,42 @@ fn batch_refuses_to_write_over_its_inputs() {
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
}
/// #7437: `convert` to its own input file, however the output is spelt, is refused before
/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492).
#[test]
fn convert_refuses_to_write_over_its_input() {
let d = tmp("convert-in-place");
write_png(&d.join("in.png"), 8, 4, 5);
let before = std::fs::read(d.join("in.png")).unwrap();
let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap();
let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
outs.push(PathBuf::from("hard.png"));
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
outs.push(PathBuf::from("soft.png"));
}
for out in &outs {
for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] {
let o = convert(out, extra);
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display());
assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display());
}
}
// Another file converts as before, leaving the original alone.
ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"]));
assert!(d.join("out.jpg").is_file());
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
// `--in-place` opts in: the result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
#[test]
fn commands_listing() {
let (out, _) = ok(bin().arg("commands"));
@@ -753,3 +859,136 @@ fn batch_plays_a_photoshop_actions_file() {
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut out, mut err) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = photocraft_cli::run(&args, &mut out, &mut err);
(code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
}
const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
#[test]
fn top_level_help_version_and_no_arguments() {
let (code, out, err) = invoke(&[]);
assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
for help in ["--help", "-h", "help"] {
let (code, out, err) = invoke(&[help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
}
for version in ["--version", "version"] {
let (code, out, err) = invoke(&[version]);
assert_eq!((code, err.as_str()), (0, ""), "{version}");
assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
}
let (code, out, err) = invoke(&["frobnicate"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("frobnicate"), "{err}");
}
/// `<sub> --help` / `-h` prints help on stdout and returns without running the subcommand
/// (`mcp` and `serve` would otherwise block on stdin).
#[test]
fn every_subcommand_help_returns_on_stdout() {
for sub in SUBCOMMANDS {
for help in ["--help", "-h"] {
let (code, out, err) = invoke(&[sub, help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
// `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
}
}
}
/// Every subcommand refuses an unknown flag (bare or `=value`) with exit 2, naming it on stderr.
#[test]
fn every_subcommand_rejects_an_unknown_flag() {
for sub in SUBCOMMANDS {
for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
let (code, out, err) = invoke(&args);
assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
assert!(err.contains("--fromat"), "{args:?}: {err}");
}
}
}
/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
#[test]
fn flag_value_forms_and_repeats() {
let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::<Vec<_>>();
let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
assert_eq!(code, 0, "{err}");
let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
assert_eq!(eq, spaced);
assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
// The last of a repeated value flag wins, whichever order they come in.
let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
assert_eq!(code, 0, "{err}");
assert_eq!(out, eq);
let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
assert_eq!(code, 0, "{err}");
assert!(out.is_empty(), "{out}");
// A bare flag takes no value; a value flag needs one.
let (code, out, err) = invoke(&["commands", "--json=1"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--json"), "{err}");
let (code, out, err) = invoke(&["commands", "--filter"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--filter"), "{err}");
}
/// Each `--params` applies to the `--cmd` before it; a `--cmd` without one gets `{}`; a
/// `--params` ahead of every `--cmd` fails naming `--params`.
#[test]
fn run_pairs_params_with_the_preceding_cmd() {
let (code, out, err) = invoke(&[
"run",
"--new",
r#"{"width":8,"height":8}"#,
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"First"}"#,
"--cmd",
"layer.new.layer",
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"Third"}"#,
"--cmd",
"document.inspect",
]);
assert_eq!(code, 0, "{err}");
let rows: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(rows.len(), 4, "{out}");
let names: Vec<&str> = rows[3]["result"]["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
let count = |n: &str| names.iter().filter(|x| **x == n).count();
assert_eq!((count("First"), count("Third")), (1, 1), "each --params applies to its own --cmd only: {names:?}");
// Three new layers plus the background; the middle one (no --params) has a default name.
assert_eq!(names.len(), 4, "{names:?}");
// Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
// with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
assert_ne!(code, 0);
assert!(err.contains("--params"), "{err}");
}
/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as
/// `run`, `info` and the app do; it wrote 72.
#[test]
fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
let d = tmp("convert-raw");
let (w, h) = (24, 16);
let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd"));
std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
ok(bin().args(["convert"]).arg(&raw).arg(&psd));
let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document;
assert_eq!(doc.resolution_dpi, 300.0);
std::fs::remove_dir_all(d).unwrap();
}
+4
View File
@@ -0,0 +1,4 @@
//! The crate's integration tests, linked into one test binary.
mod cli;
mod orientation;
+1 -1
View File
@@ -21,4 +21,4 @@ flate2 = { workspace = true }
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"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
+117 -6
View File
@@ -8,7 +8,10 @@
//! 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.
//! In one that can't, this module inflates them itself, a little slower. Some servers recognise
//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic`
//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then
//! taken as it is, by its first bytes.
//!
//! 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.
@@ -36,6 +39,34 @@ fn parts(list: &str) -> Option<Vec<String>> {
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
}
/// What the joined parts hold, told by their first bytes.
#[cfg(any(target_arch = "wasm32", test))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Payload {
/// The gzip packaging made, still to be inflated.
Gzip,
/// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser
/// already undid.
Wasm,
}
/// Enough leading bytes to tell the payloads apart.
#[cfg(any(target_arch = "wasm32", test))]
const HEAD: usize = 4;
/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or
/// `None` when it is neither, such as a web page or nothing at all.
#[cfg(any(target_arch = "wasm32", test))]
fn payload(head: &[u8]) -> Option<Payload> {
if head.starts_with(&[0x1f, 0x8b]) {
Some(Payload::Gzip)
} else if head.starts_with(b"\0asm") {
Some(Payload::Wasm)
} else {
None
}
}
/// 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>>);
@@ -69,11 +100,11 @@ mod boot {
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,
Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
};
use super::Inflater;
use super::{HEAD, Inflater, Payload};
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
@@ -172,11 +203,71 @@ mod boot {
Ok(compressed.pipe_through(&pair))
}
/// The next chunk `reader` gives, or `None` at the end of the stream.
async fn read(reader: &ReadableStreamDefaultReader) -> Result<Option<js_sys::Uint8Array>, JsValue> {
let result = JsFuture::from(reader.read()).await?;
if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() {
return Ok(None);
}
Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?))
}
/// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up
/// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending.
async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await {
let _ = reader.cancel_with_reason(&e);
return Err(e);
}
Ok(())
}
/// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it.
async fn pass_on(held: Vec<js_sys::Uint8Array>, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
for chunk in &held {
write(chunk, reader, writer).await?;
}
while let Some(chunk) = read(reader).await? {
write(&chunk, reader, writer).await?;
}
JsFuture::from(writer.close()).await?;
Ok(())
}
/// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the
/// browser has already done that.
async fn unpacked(joined: &ReadableStream) -> Result<ReadableStream, JsValue> {
let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into();
let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new());
while head.len() < HEAD {
let Some(chunk) = read(&reader).await? else { break };
let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length());
head.extend(chunk.subarray(0, wanted).to_vec());
held.push(chunk);
}
let Some(payload) = super::payload(&head) else {
let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly");
// Stops the rest of the download, which nothing will read.
let _ = reader.cancel_with_reason(&e);
return Err(e);
};
let rest = TransformStream::new()?;
let writer = rest.writable().get_writer()?;
spawn_local(async move {
if let Err(e) = pass_on(held, &reader, &writer).await {
let _ = writer.abort_with_reason(&e);
}
});
match payload {
Payload::Gzip => inflated(&rest.readable()),
Payload::Wasm => Ok(rest.readable()),
}
}
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();
@@ -187,6 +278,10 @@ mod boot {
failed.replace(Some(e));
}
});
let wasm = match unpacked(&joined.readable()).await {
Ok(wasm) => wasm,
Err(e) => return Err(cause.take().unwrap_or(e)),
};
let headers = Headers::new()?;
headers.set("Content-Type", "application/wasm")?;
let init = ResponseInit::new();
@@ -258,7 +353,7 @@ fn main() {
#[cfg(test)]
mod tests {
use super::{Inflater, MARKER, parts};
use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
@@ -317,6 +412,22 @@ mod tests {
assert!(run(b"").is_err(), "nothing at all");
}
/// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with
/// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts
/// are then the app's WebAssembly, which must start as it is, not be inflated a second time.
#[test]
fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() {
let mut wasm = b"\0asm\x01\0\0\0".to_vec();
wasm.extend(sample());
let packed = gzip(&wasm);
assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged");
assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser");
assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD");
assert_eq!(payload(b"<!DOCTYPE html>"), None, "a web page");
assert_eq!(payload(b"\0as"), None, "a cut-off wasm header");
assert_eq!(payload(b""), None, "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");
+1 -1
View File
@@ -34,7 +34,7 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
[dev-dependencies]
serde_json = { workspace = true }
+77 -18
View File
@@ -1,6 +1,8 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -15,11 +17,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .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",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign",
"afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -79,11 +82,13 @@ pub fn start() {
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let place_inbox: Inbox = Arc::default();
let served: Served = Arc::default();
load_font_manifest(served.clone(), cc.egui_ctx.clone());
let (session, warnings) = crate::preset_bridge::session(presets.clone());
preset_warnings.extend(warnings);
let mut app = PhotocraftApp::new(session, services(inbox.clone()));
let drag = listen_drag(&pen_target);
let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag)));
if !preset_warnings.is_empty() {
photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None);
}
@@ -97,7 +102,7 @@ pub fn start() {
}
let unsaved = Arc::new(AtomicBool::new(false));
guard_unload(unsaved.clone());
Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets }))
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
}),
)
.await;
@@ -140,6 +145,38 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
}
}
/// The pointer in a drag over the page, in CSS pixels relative to the viewport (`clientX`,
/// `clientY`): the last `dragover` or `drop` event's.
type DragPos = Rc<Cell<Option<(f32, f32)>>>;
/// Follows files dragged over the canvas. eframe's drop events carry no pointer position and the
/// page gets no pointer events during a drag, so this is where a drop lands: on the document's
/// canvas it places the files, elsewhere it opens them (#5749).
fn listen_drag(target: &web_sys::HtmlCanvasElement) -> DragPos {
use wasm_bindgen::closure::Closure;
let pos = DragPos::default();
for kind in ["dragover", "drop"] {
let pos = pos.clone();
// A DragEvent is a MouseEvent: only its client position is read.
let cb = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |e: web_sys::MouseEvent| pos.set(Some((e.client_x() as f32, e.client_y() as f32))));
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();
}
}
pos
}
/// `Services::cursor_pos` for the web: the last drag position in egui points, mapped as eframe
/// maps pointer events (relative to the canvas, divided by the zoom factor).
fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocraft_ui_egui::CursorPosFn {
Box::new(move |ctx: &egui::Context| {
let (x, y) = pos.get()?;
let rect = canvas.get_bounding_client_rect();
let zoom = ctx.zoom_factor();
(zoom.is_finite() && zoom > 0.0).then(|| egui::pos2((x - rect.left() as f32) / zoom, (y - rect.top() as f32) / zoom))
})
}
/// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
/// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
/// refreshed every frame by [`WebShell`].
@@ -219,10 +256,13 @@ async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, Stri
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
/// so the app's own drop path can't handle them) and feed them through the inbox.
/// so the app's own drop path can't handle them) and feed them through the inboxes.
struct WebShell {
app: PhotocraftApp,
/// Dropped files to open as documents.
inbox: Inbox,
/// Dropped files to place on the active document's canvas.
place_inbox: Inbox,
/// Read by the `beforeunload` listener ([`guard_unload`]).
unsaved: Arc<AtomicBool>,
served: Served,
@@ -283,19 +323,34 @@ impl WebShell {
}
impl eframe::App for WebShell {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
// twin press closes the menu the first one opened (#2095).
photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
if !dropped.is_empty() {
let names: Vec<String> =
dropped.iter().map(|f| f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())).collect();
// Decided now, where the drop landed: on the document's canvas the files are placed
// as layers (as on the desktop), elsewhere opened.
let places = self.app.dropped_on_canvas(ctx, &names);
let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect();
let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone());
let ctx = ctx.clone();
// One after another, so they open or place in the order they were dropped.
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
for ((f, name), place) in files {
match f.bytes_async().await {
Ok(bytes) => {
let queue = if place { &place_inbox } else { &inbox };
queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
@@ -333,11 +388,11 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox) -> Services {
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
Services {
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
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())
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
photocraft_io::import_with_options(name, bytes, opts).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())
@@ -379,11 +434,15 @@ fn services(inbox: Inbox) -> Services {
}
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
// A download may be cancelled and never reports back: it doesn't save the document.
write_unconfirmed: true,
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
})),
inbox: Some(inbox),
place_inbox: Some(place_inbox),
cursor_pos: Some(cursor_pos),
// Preferences live in the browser's localStorage.
load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())),
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
+6 -2
View File
@@ -33,6 +33,7 @@ eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11",
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -50,11 +51,12 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di
# safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the
# system alert sound for Beep When Done (services.rs, NSBeep). Already in
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
objc2 = "=0.6.4"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] }
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
@@ -65,6 +67,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done.
tempfile = "3"
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
+8
View File
@@ -11,6 +11,14 @@ fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
// the print command checks for it first and reports an error instead.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/photocraft.ico")
.set("ProductName", "PhotoCraft")
+60 -13
View File
@@ -10,8 +10,8 @@
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
//!
//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the
//! roaming profile ([`recovery_dir`]).
//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced
//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -77,18 +77,35 @@ pub fn recovery_dir() -> Option<PathBuf> {
})
}
/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every
/// few minutes, so on Windows the platform convention puts them in the machine-local
/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and
/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before
/// a crashed document is recovered. The override and portable folders keep everything in one
/// place, and other platforms already keep these folders local.
/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few
/// minutes, so with the platform convention they stay out of what is synced with the settings:
/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming
/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy.
/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default
/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that
/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy
/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored.
/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put
/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the
/// system.
///
/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed
/// document is recovered. The override and portable folders keep everything in one place.
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if cfg!(windows)
&& data.mode == Mode::Platform
&& let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty())
{
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
if data.mode == Mode::Platform {
if cfg!(windows) {
if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) {
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
}
} else if !cfg!(target_os = "macos") {
let state = env("XDG_STATE_HOME")
.map(PathBuf::from)
.filter(|s| s.is_absolute())
.or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state")));
if let Some(state) = state {
return Some(state.join("photocraft").join(RECOVERY));
}
}
}
data.dir.as_ref().map(|d| d.join(RECOVERY))
}
@@ -623,6 +640,34 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default
/// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows
/// and macOS don't read it, and an override or portable folder keeps them inside itself.
#[test]
fn autosaves_go_to_the_xdg_state_folder() {
let root = temp("recovery-xdg");
let settings = root.join("home").join(".config").join("photocraft");
let home = root.join("home");
let state = root.join("state");
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
let xdg = !cfg!(windows) && !cfg!(target_os = "macos");
let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") });
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery")));
let default = home.join(".local").join("state").join("photocraft").join("Recovery");
assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone()));
let relative = PathBuf::from("relative/state");
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone()));
let empty = PathBuf::new();
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default));
// No home at all: beside the settings, as before.
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
for mode in [Mode::Override, Mode::Portable] {
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}");
}
let _ = std::fs::remove_dir_all(&root);
}
/// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
fn autosave(dir: &Path, key: &str) {
std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
@@ -784,6 +829,7 @@ mod tests {
/// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
#[test]
fn a_launch_that_times_out_never_writes_where_a_move_removes() {
let _spawns = crate::no_child_spawns();
let root = temp("recovery-timeout");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
@@ -866,6 +912,7 @@ mod tests {
/// once the lock is released, the move happens.
#[test]
fn a_move_waits_for_another_process_holding_the_lock() {
let _spawns = crate::no_child_spawns();
let root = temp("recovery-lock");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
+20
View File
@@ -34,6 +34,13 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
/// Value for [`egui::ViewportBuilder::with_icon`]: [`window_icon`] when [`sets_runtime_icon`]
/// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon
/// on the Dock after the window opens (#7253).
pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData {
if sets_runtime_icon(exe) { window_icon() } else { egui::IconData::default() }
}
/// 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());
@@ -80,6 +87,19 @@ mod tests {
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
/// #7253: omitting `with_icon` made eframe paint its default glyph on the Dock once the window loaded.
#[test]
fn viewport_icon_blocks_eframe_default_when_runtime_icon_is_skipped() {
use std::path::Path;
let bundled = Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"));
if sets_runtime_icon(bundled) {
let icon = viewport_icon(bundled);
assert!(icon.width >= 256 && icon.width == icon.height);
} else {
assert_eq!(viewport_icon(bundled), egui::IconData::default());
}
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
+22 -1
View File
@@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
return rx;
}
};
eprintln!("photocraft: control server listening on 127.0.0.1:{port}");
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
std::thread::spawn(move || {
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let token = Arc::new(token);
@@ -47,6 +47,16 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
rx
}
/// The address `listener` is bound to: with `--control 0` the OS picks the port, and a client
/// needs that one, not the requested 0 (#6427). Falls back to the requested port if the OS
/// can't report it.
fn listening_addr(listener: &TcpListener, requested_port: u16) -> String {
match listener.local_addr() {
Ok(addr) => addr.to_string(),
Err(_) => format!("127.0.0.1:{requested_port}"),
}
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
@@ -131,6 +141,17 @@ mod tests {
use super::*;
use std::io::BufRead;
/// #6427: with port 0 the log names the port the OS bound, which a client can connect to.
#[test]
fn port_zero_logs_the_bound_port() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let bound = listener.local_addr().unwrap();
assert_ne!(bound.port(), 0);
let logged = listening_addr(&listener, 0);
assert_eq!(logged, format!("127.0.0.1:{}", bound.port()));
TcpStream::connect(&logged).unwrap();
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
+115 -14
View File
@@ -70,25 +70,61 @@ pub fn start() {
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
///
/// Asks the portal object to introspect itself rather than reading the interface's `version`
/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without
/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check
/// didn't count as absent (#5739). The interface list answers the question directly.
#[cfg(target_os = "linux")]
fn file_chooser_portal() -> Option<bool> {
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
return Some(false);
};
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
.ok()?;
match proxy.get_property::<u32>("version") {
Ok(_) => Some(true),
Err(zbus::Error::MethodError(name, _, _)) => {
let name = name.as_str();
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
absent.then_some(false)
}
Err(_) => None,
let reply = conn.call_method(
Some("org.freedesktop.portal.Desktop"),
"/org/freedesktop/portal/desktop",
Some("org.freedesktop.DBus.Introspectable"),
"Introspect",
&(),
);
match reply {
Ok(message) => match message.body().deserialize::<String>() {
Ok(xml) => Some(interface_listed(&xml)),
Err(_) => None,
},
Err(e) => classify_error(&e),
}
}
/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as
/// `FileChooser2`).
#[cfg(target_os = "linux")]
fn interface_listed(xml: &str) -> bool {
["\"", "'"].iter().any(|quote| xml.contains(&format!("<interface name={quote}org.freedesktop.portal.FileChooser{quote}")))
}
/// What a failed call says about the interface: `Some(false)` when the D-Bus error means the
/// service, object or interface isn't there, `None` for anything else (a timeout, a dropped
/// connection, an unknown error), which counts as present.
#[cfg(target_os = "linux")]
fn classify_error(e: &zbus::Error) -> Option<bool> {
use zbus::fdo::Error as Fdo;
// Names a bus daemon or portal answers with when the thing asked about is absent.
const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"];
match e {
zbus::Error::FDO(fdo) => matches!(
**fdo,
Fdo::ServiceUnknown(_)
| Fdo::NameHasNoOwner(_)
| Fdo::UnknownObject(_)
| Fdo::UnknownInterface(_)
| Fdo::UnknownMethod(_)
| Fdo::UnknownProperty(_)
| Fdo::InvalidArgs(_)
)
.then_some(false),
zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false),
_ => None,
}
}
@@ -126,4 +162,69 @@ mod tests {
assert!(!on_path("kdialog", &path));
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
mod portal {
use super::super::*;
const WITH_FILE_CHOOSER: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/introspect.dtd">
<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"><property name="version" type="u" access="read"/></interface>
<interface name="org.freedesktop.portal.FileChooser">
<method name="OpenFile"/>
</interface>
</node>"#;
const WITHOUT_FILE_CHOOSER: &str = r#"<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"/>
</node>"#;
fn method_error(name: &'static str) -> zbus::Error {
let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap();
let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap();
zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply)
}
#[test]
fn the_interface_list_says_whether_a_file_chooser_is_offered() {
assert!(interface_listed(WITH_FILE_CHOOSER));
assert!(interface_listed("<interface name='org.freedesktop.portal.FileChooser'>"), "single quotes");
assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend");
assert!(!interface_listed(r#"<interface name="org.freedesktop.portal.FileChooser2">"#), "longer name");
assert!(!interface_listed(""));
}
#[test]
fn dbus_errors_for_a_missing_interface_mean_absent() {
// zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as
// `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface")
// when no backend offers the interface (#5739).
for e in [
zbus::fdo::Error::InvalidArgs("No such interface".into()),
zbus::fdo::Error::UnknownInterface("x".into()),
zbus::fdo::Error::ServiceUnknown("x".into()),
zbus::fdo::Error::NameHasNoOwner("x".into()),
zbus::fdo::Error::UnknownObject("x".into()),
] {
let desc = format!("{e:?}");
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}");
}
}
#[test]
fn method_error_names_still_count_as_absent() {
for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] {
assert_eq!(classify_error(&method_error(name)), Some(false), "{name}");
}
assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None);
}
#[test]
fn inconclusive_errors_are_not_absent() {
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None);
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None);
assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None);
}
}
}
+269 -28
View File
@@ -12,8 +12,10 @@
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
//! where it can be reset). With `auto`, Intel and Qualcomm (Windows on ARM) adapters on Windows
//! use DX12, and when DXGI lists an Intel adapter the instance leaves Vulkan out, so the first
//! start doesn't load the Intel Vulkan driver either. `WGPU_BACKEND` still overrides everything,
//! and `--safe-gpu` forces the CPU path for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
@@ -33,6 +35,10 @@ use serde_json::{Value, json};
pub const MARKER_FILE: &str = "gpu-starting.json";
/// PCI vendor id of Intel.
pub const INTEL: u32 = 0x8086;
/// Qualcomm's vendor id as its Vulkan driver reports it.
pub const QUALCOMM: u32 = 0x5143;
/// Qualcomm's vendor id as DXGI (DX12) reports it: "QCOM".
pub const QUALCOMM_DXGI: u32 = 0x4D4F_4351;
/// Largest marker read (a marker is a few hundred bytes).
const MARKER_MAX: u64 = 64 << 10;
@@ -204,6 +210,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
})
}
/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor
/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An
/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's
/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]'
/// choice.
pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec<u32>) -> Option<wgpu::Backends> {
let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows;
(auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL)
}
/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver)
/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]).
fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec<u32> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::DX12,
flags: desc.flags,
backend_options: desc.backend_options.clone(),
..wgpu::InstanceDescriptor::new_without_display_handle()
});
crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect()
}
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
@@ -220,6 +248,18 @@ pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Com
}
}
/// The GPU makers whose Windows Vulkan driver `auto` avoids, so DX12 comes first: Intel's
/// (`igvk64.dll` crashed while the device was created, #4) and Qualcomm's Adreno driver on
/// Windows on ARM (it lost the device mid-session and the window went with it; DX12 on the same
/// machine didn't, #7537). The name is for System Info.
fn dx12_first_on_windows(vendor: u32) -> Option<&'static str> {
match vendor {
INTEL => Some("Intel"),
QUALCOMM | QUALCOMM_DXGI => Some("Qualcomm"),
_ => None,
}
}
/// What adapter selection needs to know about an adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Candidate {
@@ -228,13 +268,16 @@ pub struct Candidate {
pub backend: wgpu::Backend,
/// Can present to the window.
pub surface_ok: bool,
/// Its name matches the adapter the user asked for ([`adapter_matches`]).
pub wanted: bool,
}
/// 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).
/// Intel and Qualcomm on Windows ([`dx12_first_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).
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 ty = match a.device_type {
@@ -250,7 +293,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
_ => ty,
};
let be = match a.backend {
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
wgpu::Backend::Dx12 if os == Os::Windows && dx12_first_on_windows(a.vendor).is_some() => 0,
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
wgpu::Backend::Dx12 => 2,
wgpu::Backend::Gl => 3,
@@ -259,19 +302,62 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
(fallback, ty, be)
}
/// The adapter to use among `adapters` (`None` when there is none).
/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for
/// win over the rest when any of them can present; otherwise the ranking decides (see
/// [`wanted_missed`]).
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
let usable = |a: &&Candidate| a.surface_ok;
let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted);
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
}
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
/// same vendor was available).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
let Some(c) = adapters.get(chosen) else { return false };
os == Os::Windows
&& c.vendor == INTEL
&& c.backend == wgpu::Backend::Dx12
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive
/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds
/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing.
pub fn adapter_matches(name: &str, wanted: &str) -> bool {
photocraft_engine::prefs::gpu_adapter_matches(name, wanted)
}
/// The user asked for an adapter and none of `adapters` that can present is it.
pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool {
wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted)
}
/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a
/// single Metal path, and neither is tested with a requested adapter.
pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux");
/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode,
/// which wants a software adapter whatever the preference says.
pub fn applicable_adapter(wanted: Option<String>, backend: GpuBackend, linux: bool) -> Option<String> {
wanted.filter(|_| linux && backend != GpuBackend::Cpu)
}
/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act
/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the
/// launch may still be forwarded to the running window.
pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool {
env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu
}
/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else
/// `performance.gpuAdapter`; `None` is automatic.
pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
}
/// The maker's name when picking `chosen` applied the Windows DX12 default of
/// [`dx12_first_on_windows`]: a Vulkan adapter of the same maker was available, or, for Intel,
/// `vulkan_left_out` ([`windows_auto_backends`] left Vulkan out of the instance).
pub fn dx12_default_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> Option<&'static str> {
let c = adapters.get(chosen)?;
if os != Os::Windows || c.backend != wgpu::Backend::Dx12 {
return None;
}
let maker = dx12_first_on_windows(c.vendor)?;
let applied =
(vulkan_left_out && c.vendor == INTEL) || adapters.iter().any(|a| a.backend == wgpu::Backend::Vulkan && dx12_first_on_windows(a.vendor) == Some(maker));
applied.then_some(maker)
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -283,9 +369,9 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
/// selection (Intel and Qualcomm on Windows, software adapters for `cpu`) and the marker update
/// once the adapter is chosen. `note` receives a remark for System Info (e.g. the DX12 default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
create.instance_descriptor.backends = b;
@@ -298,7 +384,13 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
let env = std::env::var("WGPU_DX12_COMPILER").ok();
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
}
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor));
if let Some(b) = without_vulkan {
create.instance_descriptor.backends = b;
}
let vulkan_left_out = without_vulkan.is_some();
let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE);
let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)));
if select {
let backend = plan.backend;
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
@@ -311,17 +403,24 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
device_type: i.device_type,
backend: i.backend,
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)),
}
})
.collect();
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice"));
}
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
if backend == GpuBackend::Auto
&& let Some(maker) = dx12_default_applied(&cands, i, os, vulkan_left_out)
{
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
Some(format!("{maker} graphics on Windows: using DirectX 12 (the {maker} Vulkan driver is known to crash)"));
}
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
}));
@@ -443,6 +542,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
/// opens; empty (automatic) when the file or value is missing.
pub fn read_gpu_adapter(path: Option<&Path>) -> String {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect()
}
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
/// turned on), read before the window opens: it picks the surface's present mode.
pub fn read_low_latency(path: Option<&Path>) -> bool {
@@ -604,7 +710,82 @@ mod tests {
}
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
Candidate { vendor, device_type, backend, surface_ok: true }
Candidate { vendor, device_type, backend, surface_ok: true, wanted: false }
}
#[test]
fn a_requested_adapter_beats_the_automatic_ranking() {
use wgpu::{Backend as B, DeviceType as T};
// The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU.
let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc");
a[2].wanted = true;
assert_eq!(pick(&a, Auto, Os::Other), Some(2));
a[2].wanted = false;
a[0].wanted = true;
assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for");
// A wanted adapter that can't present is skipped, not an error.
a[0].surface_ok = false;
assert_eq!(pick(&a, Auto, Os::Other), Some(1));
assert!(wanted_missed(&a, Some("iris")));
assert!(!wanted_missed(&a, None));
a[0].surface_ok = true;
assert!(!wanted_missed(&a, Some("iris")));
// Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides.
let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
gl.iter_mut().for_each(|c| c.wanted = true);
assert_eq!(pick(&gl, Auto, Os::Other), Some(1));
}
#[test]
fn an_adapter_request_applies_on_linux_outside_cpu_mode() {
let w = || Some("nvidia".to_string());
assert_eq!(applicable_adapter(w(), Auto, true), w());
assert_eq!(applicable_adapter(w(), Vulkan, true), w());
assert_eq!(applicable_adapter(w(), Cpu, true), None);
assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection");
assert_eq!(applicable_adapter(None, Auto, true), None);
}
#[test]
fn an_adapter_override_keeps_its_own_process_only_when_it_applies() {
let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux);
assert!(on(Some("nvidia"), RenderingMode::Auto, true));
assert!(on(Some("nvidia"), RenderingMode::Gpu, true));
assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode");
assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux");
assert!(!on(Some(" "), RenderingMode::Auto, true));
assert!(!on(None, RenderingMode::Auto, true));
}
#[test]
fn adapter_names_match_loosely_and_the_environment_wins() {
assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M "));
assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc"));
assert!(!adapter_matches("anything", ""));
assert!(!adapter_matches("anything", " "));
assert_eq!(wanted_adapter(None, ""), None);
assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into()));
assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into()));
assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256));
}
#[test]
fn gpu_adapter_is_read_before_the_window_opens() {
let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_gpu_adapter(Some(&file))
};
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
assert_eq!(read(r#"{"performance":{}}"#), "");
assert_eq!(read("not json"), "");
assert_eq!(read_gpu_adapter(None), "");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
@@ -613,13 +794,13 @@ mod tests {
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
assert!(intel_dx12_applied(&intel, i, Os::Windows));
assert_eq!(dx12_default_applied(&intel, i, Os::Windows, false), Some("Intel"));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
assert_eq!(dx12_default_applied(&nv, i, Os::Windows, false), None);
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
@@ -630,6 +811,66 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
/// #7537: on Windows on ARM the Adreno Vulkan driver lost the device mid-session and took the
/// window with it; DX12 on the same machine didn't. DXGI and Vulkan report different vendor ids.
#[test]
fn qualcomm_on_windows_defaults_to_dx12() {
use wgpu::{Backend as B, DeviceType as T};
let adreno = [cand(QUALCOMM, T::IntegratedGpu, B::Vulkan), cand(QUALCOMM_DXGI, T::IntegratedGpu, B::Dx12)];
let i = pick(&adreno, Auto, Os::Windows).unwrap();
assert_eq!(adreno[i].backend, B::Dx12);
assert_eq!(dx12_default_applied(&adreno, i, Os::Windows, false), Some("Qualcomm"));
// Only Vulkan offered: still Vulkan, and no note.
assert_eq!(pick(&adreno[..1], Auto, Os::Windows), Some(0));
assert_eq!(dx12_default_applied(&adreno, 0, Os::Windows, false), None);
// An Intel Vulkan adapter doesn't make a Qualcomm DX12 pick "the default".
let mixed = [cand(INTEL, T::IntegratedGpu, B::Vulkan), adreno[1]];
assert_eq!(dx12_default_applied(&mixed, 1, Os::Windows, false), None);
// Vulkan left out of the instance (an Intel adapter was listed) speaks only for Intel.
assert_eq!(dx12_default_applied(&adreno[1..], 0, Os::Windows, true), None);
// Elsewhere Vulkan stays first.
assert_eq!(adreno[pick(&adreno, Auto, Os::Other).unwrap()].backend, B::Vulkan);
}
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
/// Windows creates the instance without Vulkan.
#[test]
fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() {
use wgpu::{Backend as B, DeviceType as T};
const NVIDIA: u32 = 0x10de;
const AMD: u32 = 0x1002;
const WARP: u32 = 0x1414;
let auto = plan(Auto, None, None, false, Os::Windows);
let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL);
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl);
// Intel iGPU + discrete GPU: the discrete GPU is then used on DX12.
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl);
// No Intel adapter: egui's default backends, Vulkan included.
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None);
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None);
assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None);
// `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms
// don't list the DX12 adapters at all.
let not_listed = || -> Vec<u32> { panic!("DX12 adapters listed") };
for (p, os) in [
(plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows),
(plan(Auto, None, None, true, Os::Windows), Os::Windows),
(plan(Vulkan, None, None, false, Os::Windows), Os::Windows),
(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows),
(plan(Auto, None, None, false, Os::Other), Os::Other),
(plan(Auto, None, None, false, Os::Mac), Os::Mac),
] {
assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}");
}
// Without Vulkan adapters to compare with, System Info still names the Intel default.
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&listed, Auto, Os::Windows).unwrap();
assert_eq!(i, 0);
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, true), Some("Intel"));
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, false), None);
}
#[test]
fn cpu_prefers_software_adapters() {
use wgpu::{Backend as B, DeviceType as T};
@@ -894,7 +1135,7 @@ mod tests {
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let plan = plan(Auto, None, None, false, Os::Windows);
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
@@ -918,13 +1159,13 @@ mod tests {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
let before = before.instance_descriptor.flags;
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
}
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
+217 -29
View File
@@ -4,7 +4,9 @@
//! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/photocraft.log`
//! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to
//! `.2`), so the log of a run that crashed survives the next launch.
//! `.2`), so the log of a run that crashed survives the next launch. A start while another
//! instance is still running leaves that instance's log alone and writes `photocraft-<pid>.log`
//! (see [`rotate`]).
//!
//! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
@@ -15,7 +17,7 @@
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
use std::fs::File;
use std::fs::{File, OpenOptions, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
@@ -89,37 +91,112 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// Held by the instance that writes [`LOG_FILE`], from its start until it exits. A separate lock
/// file rather than a lock on the log itself: Windows locks are mandatory, and a locked log could
/// not be opened for a bug report while the app runs.
pub const OWNER_LOCK: &str = "photocraft.lock";
/// Held while one start rotates and prunes the logs, so two starts at once can't interleave.
pub const ROTATE_LOCK: &str = "rotate.lock";
/// The log of an instance started while another one was writing [`LOG_FILE`].
fn instance_log(dir: &Path, pid: u32) -> PathBuf {
dir.join(format!("photocraft-{pid}.log"))
}
/// The lock file that marks [`instance_log`] as in use.
fn instance_lock(dir: &Path, pid: u32) -> PathBuf {
dir.join(format!("photocraft-{pid}.lock"))
}
/// The process id in an [`instance_log`] file name.
fn instance_pid(name: &str) -> Option<u32> {
let digits = name.strip_prefix("photocraft-")?.strip_suffix(".log")?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
fn open_lock(path: &Path) -> std::io::Result<File> {
OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)
}
/// Take `file`'s lock without waiting: `Ok(true)` holds it, `Ok(false)` when another process holds it.
fn try_hold(file: &File) -> std::io::Result<bool> {
match file.try_lock() {
Ok(()) => Ok(true),
Err(TryLockError::WouldBlock) => Ok(false),
Err(TryLockError::Error(e)) => Err(e),
}
}
/// Open a fresh, empty log file in `dir` (created if missing).
///
/// Normally the previous logs shift up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
/// dropped). While another instance is still writing [`LOG_FILE`] (it holds [`OWNER_LOCK`]), the
/// chain is left alone and this start writes `photocraft-<pid>.log` instead: rotating would move
/// that instance's live log away and, a few launches later, delete it (#6432). Such side-by-side
/// logs are pruned to the newest [`KEEP`] once their instances have exited. On a file system
/// without locks every start rotates, as before.
pub fn rotate(dir: &Path) -> std::io::Result<LogFile> {
std::fs::create_dir_all(dir)?;
// Waits only for another start's few file operations; without locking support, go on.
let guard = open_lock(&dir.join(ROTATE_LOCK)).ok().filter(|g| g.lock().is_ok());
let log = rotate_locked(dir);
drop(guard);
log
}
fn rotate_locked(dir: &Path) -> std::io::Result<LogFile> {
prune_instance_logs(dir);
let owner = open_lock(&dir.join(OWNER_LOCK))?;
let state = try_hold(&owner);
if let Ok(false) = state {
let pid = std::process::id();
let lock = open_lock(&instance_lock(dir, pid))?;
let held = try_hold(&lock).unwrap_or(false);
let path = instance_log(dir, pid);
let file = File::create(&path)?;
return Ok(LogFile { path, file, owner: held.then_some(lock) });
}
// This start holds the owner lock (or the file system has no locks).
let held = state.unwrap_or(false);
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
@@ -128,7 +205,40 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
Ok(LogFile { path, file, owner: held.then_some(owner) })
}
/// Remove the side-by-side logs of instances that have exited, all but the newest [`KEEP`].
/// A log whose lock file is held belongs to a running instance and is never touched. Failures
/// are ignored: pruning only saves disk space.
fn prune_instance_logs(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut finished: Vec<(SystemTime, u32)> = Vec::new();
for entry in entries.flatten() {
let Some(pid) = entry.file_name().to_str().and_then(instance_pid) else { continue };
// A missing lock file means nobody holds it; an unknown lock state counts as running.
let running = match OpenOptions::new().read(true).write(true).open(instance_lock(dir, pid)) {
Ok(lock) => !try_hold(&lock).unwrap_or(false),
Err(_) => false,
};
if !running {
let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
finished.push((modified, pid));
}
}
finished.sort_unstable_by(|a, b| b.cmp(a));
for &(_, pid) in finished.iter().skip(KEEP) {
let _ = std::fs::remove_file(instance_log(dir, pid));
let _ = std::fs::remove_file(instance_lock(dir, pid));
}
}
/// A log file opened by [`rotate`].
pub struct LogFile {
pub path: PathBuf,
pub file: File,
/// The held lock that marks `path` as in use; `None` where locking isn't supported.
owner: Option<File>,
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
@@ -205,17 +315,20 @@ pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
/// The lock that marks the attached log file as in use (see [`rotate`]); held until exit.
owner: Mutex<Option<File>>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
*self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner;
Ok(path)
}
}
@@ -312,13 +425,28 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
std::fs::write(&path, format!("run {run}")).expect("write");
@@ -330,6 +458,66 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// #6432: a second launch moved the running instance's live log to `.1.log`, a third to
/// `.2.log`, and a fourth deleted it.
#[test]
fn a_second_start_leaves_the_running_instance_log_alone() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("second");
let mut first = rotate(&dir).expect("first start");
assert_eq!(first.path, dir.join(LOG_FILE));
first.file.write_all(b"first, before\n").expect("write");
for _ in 0..KEEP + 1 {
let second = rotate(&dir).expect("second start");
assert_eq!(second.path, instance_log(&dir, std::process::id()));
assert_eq!(read(&second.path), "");
}
first.file.write_all(b"first, after\n").expect("write");
assert_eq!(read(&dir.join(LOG_FILE)), "first, before\nfirst, after\n");
assert!(!dir.join("photocraft.1.log").exists(), "the running instance's log was rotated");
// Once the first instance is gone, the next start rotates as before.
drop(first);
let third = rotate(&dir).expect("third start");
assert_eq!(third.path, dir.join(LOG_FILE));
assert_eq!(read(&dir.join("photocraft.1.log")), "first, before\nfirst, after\n");
drop(third);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn instance_logs_are_pruned_only_when_no_one_holds_them() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("prune");
let first = rotate(&dir).expect("first start");
// Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file
// (as if its lock file had been removed), the others an unlocked one.
let now = SystemTime::now();
for (age, pid) in [(400u64, 1u32), (300, 2), (200, 3), (100, 4)] {
let path = instance_log(&dir, pid);
std::fs::write(&path, format!("run {pid}")).expect("write");
File::options().write(true).open(&path).expect("open").set_modified(now - Duration::from_secs(age)).expect("mtime");
if pid != 1 {
std::fs::write(instance_lock(&dir, pid), "").expect("lock file");
}
}
// One older still running: its log is never removed, however old.
let live = instance_log(&dir, 5);
std::fs::write(&live, "live").expect("write");
File::options().write(true).open(&live).expect("open").set_modified(now - Duration::from_secs(900)).expect("mtime");
let held = File::create(instance_lock(&dir, 5)).expect("lock file");
held.try_lock().expect("hold");
let second = rotate(&dir).expect("second start");
assert_eq!(second.path, instance_log(&dir, std::process::id()));
// The newest KEEP finished ones stay, the rest go with their lock files.
assert!(instance_log(&dir, 4).exists() && instance_log(&dir, 3).exists());
assert!(!instance_log(&dir, 2).exists() && !instance_log(&dir, 1).exists());
assert!(!instance_lock(&dir, 2).exists());
assert_eq!(read(&live), "live");
drop((first, second, held));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
@@ -355,7 +543,7 @@ mod tests {
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
@@ -368,7 +556,7 @@ mod tests {
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
@@ -384,7 +572,7 @@ mod tests {
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
+375 -100
View File
@@ -3,7 +3,12 @@
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [files…]`
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [--] [files…]`
//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version).
//!
//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo
//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file
//! whose name starts with `-` goes after `--`.
//!
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
@@ -58,13 +63,15 @@ mod photoshop_settings;
#[cfg(target_os = "linux")]
mod portal_save;
mod presentation;
// The print portal needs the session bus (zbus, Linux only); its pure helpers are tested everywhere.
#[cfg(any(target_os = "linux", test))]
mod print_portal;
mod screen_color;
mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
mod window_frame;
@@ -95,9 +102,7 @@ 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 };
let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref()));
eframe::NativeOptions {
viewport: viewport
.with_app_id(APP_ID)
@@ -149,21 +154,188 @@ fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
}
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
/// when there are none, else 2 (a command-line usage error), never 0.
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
/// Process exit status for the collected usage errors (a bad `--control` /
/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a
/// command-line usage error), never 0.
fn usage_exit_code(errors: &[String]) -> Option<i32> {
if errors.is_empty() { None } else { Some(2) }
}
/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs.
const USAGE: &str = "\
Usage: photocraft [options] [--] [files…]
Opens PhotoCraft with the given files.
Options:
-h, --help Print this help and exit
--version Print the version, commit and build date and exit
--safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash)
--in-window-menus macOS: keep the menus inside the window instead of the menu bar
--new-instance Windows and Linux: start a separate PhotoCraft instead of opening
the files in the one already running
--present-mode <mode> How frames reach the display: fifo, autovsync or novsync
--frame-latency <n> Frames queued for the display: 1 to 3
--stroke-trace <stderr|file.csv>
Time each brush stroke's frames
--control <port> Start the localhost JSON-lines control server on this port
--control-token <64-hex> Control server token (default: a new random one, printed)
--control-token-file <path> Read the control server token from this file, or create it
with a new one
--automation-read-root <dir> Directory the control server may read files from
--automation-write-root <dir>
Directory the control server may write files to
-- Treat every later argument as a file, even one starting with `-`
A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored.
Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE,
PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE,
PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE,
PHOTOCRAFT_CONFIG_DIR.
";
/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when
/// stdout is closed or a pipe (`photocraft --help | head -1`).
fn print_stdout(text: &str) {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
}
/// Everything the desktop launcher takes from the command line, on top of the environment
/// defaults handed in to `parse_launch_args`.
#[derive(Debug, Default, PartialEq, Eq)]
struct LaunchArgs {
control_port: Option<u16>,
control_token: Option<String>,
control_token_file: Option<std::path::PathBuf>,
automation_read_root: Option<std::path::PathBuf>,
automation_write_root: Option<std::path::PathBuf>,
files: Vec<String>,
/// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
/// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
/// reported in a notice once the app is up; a control token is a usage error like a bad port.
unreadable_paths: Vec<String>,
safe_gpu: bool,
new_instance: bool,
/// Options that apply only to the process they're given to; a launch with any keeps its own.
launch_options: Vec<&'static str>,
present_mode_arg: Option<String>,
frame_latency_arg: Option<String>,
stroke_trace_arg: Option<String>,
in_window_menus: bool,
/// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
help: bool,
/// `--version`: print the version and exit 0; parsing stops there.
version: bool,
/// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
/// list fails the launch with exit status 2 (issues #701, #6429, #6431).
errors: Vec<String>,
}
/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
fn parse_launch_args(args: impl Iterator<Item = std::ffi::OsString>, mut out: LaunchArgs) -> LaunchArgs {
let mut args = args;
// After `--`, every argument is a file, even one that starts with `-`.
let mut only_files = false;
while let Some(a) = args.next() {
if only_files {
match a.into_string() {
Ok(s) => out.files.push(s),
Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
}
continue;
}
match a.to_str() {
Some("--") => only_files = true,
Some("--help" | "-h") => {
out.help = true;
break;
}
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => out.control_port = Some(port),
Err(error) => out.errors.push(error),
},
None => out.errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => out.control_token = Some(token),
Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => out.errors.push("--control-token: missing token value (expected `--control-token <token>`)".to_string()),
},
Some("--control-token-file") => match args.next() {
Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file <path>`)".to_string()),
},
Some("--automation-read-root") => {
out.launch_options.push("--automation-read-root");
match args.next() {
Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root <dir>`)".to_string()),
}
}
Some("--automation-write-root") => {
out.launch_options.push("--automation-write-root");
match args.next() {
Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root <dir>`)".to_string()),
}
}
Some("--safe-gpu") => {
out.launch_options.push("--safe-gpu");
out.safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
out.launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => out.present_mode_arg = Some(v),
"--frame-latency" => out.frame_latency_arg = Some(v),
_ => out.stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
out.launch_options.push("--in-window-menus");
out.in_window_menus = true;
}
Some("--new-instance") => out.new_instance = true,
Some("--version") => {
out.version = true;
break;
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
// and then fail to open the flag as a file (#6431).
Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
Some(s) => out.files.push(s.to_owned()),
None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
}
}
out
}
#[cfg(test)]
mod control_port_tests {
use super::{control_args_exit_code, parse_control_port};
use super::{parse_control_port, usage_exit_code};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(control_args_exit_code(&[]), None);
assert_eq!(usage_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(control_args_exit_code(&errors), Some(2));
assert_eq!(usage_exit_code(&errors), Some(2));
}
#[test]
@@ -184,105 +356,152 @@ mod control_port_tests {
}
}
#[cfg(test)]
mod launch_args_tests {
use super::{LaunchArgs, parse_launch_args, usage_exit_code};
use std::ffi::OsString;
fn parse(values: &[&str]) -> LaunchArgs {
parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
}
#[test]
fn flags_without_a_value_are_usage_errors() {
// Issue #6429: a missing value must fail the launch, not silently fall back to a
// generated token or to no root.
for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
let parsed = parse(&[flag]);
assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
assert_eq!(usage_exit_code(&parsed.errors), Some(2));
}
}
#[test]
fn missing_value_does_not_clear_an_environment_default() {
// `--automation-read-root` with no value must not drop a root set through the env var.
let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
}
#[test]
fn values_are_parsed_and_no_errors_remain() {
let parsed = parse(&[
"--control",
"50494",
"--control-token",
"abcd",
"--control-token-file",
"/tmp/token",
"--automation-read-root",
"/r",
"--automation-write-root",
"/w",
"--safe-gpu",
"--in-window-menus",
"--new-instance",
"--present-mode",
"mailbox",
"photo.psd",
]);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
assert_eq!(parsed.control_port, Some(50494));
assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
assert!(parsed.safe_gpu);
assert!(parsed.in_window_menus);
assert!(parsed.new_instance);
assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
}
#[test]
fn version_stops_parsing() {
let parsed = parse(&["--version", "--control"]);
assert!(parsed.version);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let parsed = parse(&["-h", "--safegpu", "--control"]);
assert!(parsed.help);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
assert!(!parsed.help);
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
let mut control_port: Option<u16> = None;
let mut control_arg_errors: Vec<String> = Vec::new();
let mut launch = LaunchArgs {
automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
..Default::default()
};
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
Ok(port) => launch.control_port = Some(port),
Err(error) => launch.errors.push(error),
}
}
let mut control_token = None;
let mut control_token_file = None;
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
let mut files = Vec::new();
// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
// reported in a notice once the app is up; a control token is a usage error like a bad port.
let mut unreadable_paths: Vec<String> = Vec::new();
let mut safe_gpu = false;
let mut new_instance = false;
// Options that apply only to the process they're given to; a launch with any keeps its own.
let mut launch_options: Vec<&'static str> = Vec::new();
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
while let Some(a) = args.next() {
match a.to_str() {
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
},
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => control_token = Some(token),
Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => control_token = None,
},
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
Some("--automation-read-root") => {
launch_options.push("--automation-read-root");
automation_read_root = args.next().map(std::path::PathBuf::from);
}
Some("--automation-write-root") => {
launch_options.push("--automation-write-root");
automation_write_root = args.next().map(std::path::PathBuf::from);
}
Some("--safe-gpu") => {
launch_options.push("--safe-gpu");
safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => present_mode_arg = Some(v),
"--frame-latency" => frame_latency_arg = Some(v),
_ => stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
launch_options.push("--in-window-menus");
in_window_menus = true;
}
Some("--new-instance") => new_instance = true,
Some("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
Some(s) => files.push(s.to_owned()),
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
}
launch = parse_launch_args(std::env::args_os().skip(1), launch);
if launch.help {
print_stdout(USAGE);
return Ok(());
}
if launch.version {
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
return Ok(());
}
// A malformed control port must not silently drop the control server (issue #701): name the
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = control_args_exit_code(&control_arg_errors) {
for error in &control_arg_errors {
// A malformed control port must not silently drop the control server (issue #701), a control
// or automation flag without its value must not silently fall back to a generated token or
// to no root (issue #6429), and an unknown option must not open the window (#6431): name the
// bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit
// status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = usage_exit_code(&launch.errors) {
for error in &launch.errors {
eprintln!("photocraft: {error}");
}
eprintln!("Run `photocraft --help` for usage.");
std::process::exit(code);
}
let LaunchArgs {
control_port,
control_token,
control_token_file,
automation_read_root,
automation_write_root,
files,
unreadable_paths,
safe_gpu,
new_instance,
mut launch_options,
present_mode_arg,
frame_latency_arg,
stroke_trace_arg,
in_window_menus,
help: _,
version: _,
errors: _,
} = launch;
// The log file lives under the settings directory; opened after the arguments, so `--version`
// and usage errors leave no file behind. Records logged until now are written to it first.
@@ -294,12 +513,24 @@ fn main() -> eframe::Result {
}
}
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
}
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
// shared between users), and not for automation, `--new-instance` or per-process options.
#[cfg(not(target_os = "macos"))]
let single_instance = {
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
// keeps its own process instead of handing its files over and being ignored.
// Folded in here, inside the block that only non-macOS builds compile, so the binding
// above keeps its `mut` off macOS, where nothing is folded in.
let mut launch_options = launch_options;
launch_options.extend_from_slice(&single_instance::env_launch_options());
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
@@ -405,6 +636,11 @@ fn main() -> eframe::Result {
let tablet_monitor = std::cell::OnceCell::new();
#[cfg(target_os = "macos")]
let tablet_monitor = &tablet_monitor;
// The Windows pointer message hook; dropping it unhooks when the event loop returns.
#[cfg(target_os = "windows")]
let tablet_monitor_win = std::cell::OnceCell::new();
#[cfg(target_os = "windows")]
let tablet_monitor_win = &tablet_monitor_win;
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
@@ -456,7 +692,9 @@ fn main() -> eframe::Result {
}
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
let wanted_adapter =
gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone());
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
@@ -499,7 +737,15 @@ fn main() -> eframe::Result {
services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some());
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
#[cfg(target_os = "linux")]
let session = {
let mut session = Session::new();
session.print_spooler = print_portal::spooler();
session
};
#[cfg(not(target_os = "linux"))]
let session = Session::new();
let mut app = PhotocraftApp::new(session, services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = custom_titlebar;
@@ -544,9 +790,19 @@ fn main() -> eframe::Result {
info.canvas = "cpu".into();
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
app.perf.gpu_info.set_available(&rs.available_adapters);
// Every egui texture uploads through this adapter, whichever canvas draws:
// Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs).
photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info());
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
// Keep an earlier note, such as a requested adapter that couldn't be used.
let software = "No compatible hardware graphics adapter; using software graphics.";
let fallback = &mut app.perf.gpu_info.fallback;
*fallback = Some(match fallback.take() {
Some(earlier) => format!("{earlier}. {software}"),
None => software.into(),
});
}
if !software_window
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
@@ -555,8 +811,9 @@ fn main() -> eframe::Result {
{
app.set_wgpu(rs);
} else {
// The window still draws with wgpu: record its errors instead of panicking.
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
// The window still draws with wgpu: record its errors instead of panicking,
// and notice when the device is lost (#3438).
app.watch_window_device(&rs.device);
}
}
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
@@ -623,8 +880,13 @@ fn main() -> eframe::Result {
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
let _ = tablet_monitor.set(monitor);
}
#[cfg(target_os = "windows")]
if let Some(monitor) = tablet::install_windows() {
let _ = tablet_monitor_win.set(monitor);
}
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, display);
app.services.right_button_down = tablet::right_button_service();
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
@@ -690,6 +952,19 @@ fn main() -> eframe::Result {
result
}
/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock
/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file,
/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held
/// for a moment.
#[cfg(test)]
pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(());
/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile.
#[cfg(test)]
pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> {
CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
#[test]
+1
View File
@@ -495,6 +495,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let t0 = std::time::Instant::now();
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
assert!(e.contains("took longer than"), "{e}");
+16 -4
View File
@@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option<PathBuf> {
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
}
/// Which file type's extensions belong on the name the dialog returned: the one it reported, or,
/// when the dialog offered only one type, that one. It reports no pick then, and the name it was
/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as
/// `Swatches.aco`. `None` when the answer names no type this dialog offered.
fn chosen_type(filters: &[Filter], picked: Option<usize>) -> Option<usize> {
match picked {
Some(i) if i < filters.len() => Some(i),
None if filters.len() == 1 => Some(0),
_ => None,
}
}
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
/// picked. When that changes the name to one that already exists, the dialog opens again under
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
@@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul
loop {
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
// With one type offered there is nothing to pick, and a typed extension is the user's.
let exts: &[String] = match picked {
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
_ => &[],
// A typed extension is the user's own: `with_chosen_type` keeps one it recognises.
let exts: &[String] = match chosen_type(&req.filters, picked) {
Some(i) => &req.filters[i].1,
None => &[],
};
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
if path == saved.path || !path.exists() {
+36 -1
View File
@@ -36,17 +36,22 @@ fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std:
/// once the user is done with it (here after 300 ms).
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead;
let spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut daemon =
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
drop(spawns);
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
let seen: Seen = Arc::default();
let log = seen.clone();
// A stream only gets the messages that arrive after it exists: made in the thread, it could
// miss a `SaveFile` sent before the thread got going, and the call would wait forever.
let messages = zbus::blocking::MessageIterator::from(&server);
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in zbus::blocking::MessageIterator::from(&server) {
for msg in messages {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
@@ -230,3 +235,33 @@ fn a_single_file_type_keeps_the_typed_extension() {
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
daemon.kill().ok();
}
/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and
/// that one type gives it its extension instead of a file named `Swatches` being written.
#[test]
fn a_single_file_type_gives_the_bare_name_its_extension() {
let typed: &'static str = "file:///tmp/photocraft-none/Swatches";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco")));
daemon.kill().ok();
}
/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`,
/// so the rule itself is checked here too, which needs none.
#[test]
fn the_type_that_gives_the_extension_is_the_one_picked() {
let one: Vec<Filter> = vec![("Color Swatches".into(), vec!["aco".into()])];
// One type offered: the dialog reports no pick, and it was given the stem.
assert_eq!(chosen_type(&one, None), Some(0));
assert_eq!(chosen_type(&one, Some(0)), Some(0));
let many: Vec<Filter> = request("test").filters;
assert_eq!(chosen_type(&many, Some(1)), Some(1));
// Several types and no pick: nothing to add, as before.
assert_eq!(chosen_type(&many, None), None);
// An answer naming a type this dialog never offered is not a pick either.
assert_eq!(chosen_type(&many, Some(99)), None);
}
+239
View File
@@ -0,0 +1,239 @@
//! Printing through the XDG Print portal (`org.freedesktop.portal.Print`): the system print
//! dialog, which a sandboxed app can reach without a CUPS socket. The engine prints over IPP
//! when it can reach a print server (`photocraft-ipp`); this is its fallback when it cannot, and
//! the only route with `PHOTOCRAFT_PRINT_PORTAL=1` (`0` disables it). `PreparePrint` shows the
//! dialog seeded with the job's copies and paper; `Print` hands it the PDF. Both answer through a
//! `Request` object's `Response` signal, which this module waits for on its own thread so the
//! window keeps painting while the dialog is open; the outcome goes to the log.
//! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html
#[cfg(target_os = "linux")]
use photocraft_engine::print_cmds::{PrintJob, Spooler};
/// The spooler to install on the session, unless `PHOTOCRAFT_PRINT_PORTAL=0`. The portal itself
/// is looked up per job, so a missing one costs nothing at start-up.
#[cfg(target_os = "linux")]
pub fn spooler() -> Option<Spooler> {
let setting = std::env::var("PHOTOCRAFT_PRINT_PORTAL").ok();
if setting.as_deref() == Some("0") {
return None;
}
Some(Spooler { name: "portal", preferred: setting.as_deref() == Some("1"), run: std::sync::Arc::new(print) })
}
/// Opens the system print dialog for the job and returns as soon as it is up.
#[cfg(target_os = "linux")]
fn print(job: &PrintJob) -> Result<String, String> {
let conn = zbus::blocking::connection::Builder::session()
.and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build())
.map_err(|e| format!("no session bus for the print portal ({e}); print to PDF instead"))?;
let proxy = portal(&conn)?;
let version: u32 = proxy.get_property("version").map_err(|e| format!("no print portal on this desktop ({e}); print to PDF instead"))?;
// The document goes to a private temporary file (0600, created atomically by `tempfile`),
// not a guessable name in a shared /tmp. The guard owns the file: it lives on the portal
// thread until the dialog is done, and a failure to start that thread drops it here.
let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(|e| format!("cannot create the print file: {e}"))?;
std::io::Write::write_all(&mut file, &job.pdf).map_err(|e| format!("cannot write the print file: {e}"))?;
let (title, settings, page_setup) = (job.title.clone(), settings(job), page_setup(job));
std::thread::Builder::new()
.name("print-portal".into())
.spawn(move || {
let outcome = dialog(&conn, &title, &settings, &page_setup, file.path());
drop(file);
match outcome {
Ok(what) => log::info!("print portal (v{version}): {what}"),
Err(e) => log::warn!("print portal (v{version}): {e}"),
}
})
.map_err(|e| format!("cannot start the print thread: {e}"))?;
Ok("the system print dialog".into())
}
#[cfg(target_os = "linux")]
fn portal(conn: &zbus::blocking::Connection) -> Result<zbus::blocking::Proxy<'static>, String> {
zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Print")
.map_err(|e| e.to_string())
}
/// `PreparePrint` then `Print`, each waited for on its request's `Response` signal.
#[cfg(target_os = "linux")]
fn dialog(
conn: &zbus::blocking::Connection,
title: &str,
settings: &[(&'static str, Seed)],
page_setup: &[(&'static str, Seed)],
pdf: &std::path::Path,
) -> Result<String, String> {
use std::collections::HashMap;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
type Dict<'a> = HashMap<&'a str, Value<'a>>;
fn as_dict(pairs: &[(&'static str, Seed)]) -> Dict<'static> {
pairs
.iter()
.map(|(k, v)| {
let value = match v {
Seed::Text(t) => Value::from(t.clone()),
Seed::Mm(mm) => Value::from(*mm),
};
(*k, value)
})
.collect()
}
let proxy = portal(conn)?;
let sender = conn.unique_name().map(|n| n.as_str().to_string()).ok_or("the bus connection has no name")?;
let wait = |token: &str| -> Result<zbus::blocking::proxy::SignalIterator<'static>, String> {
let path = request_path(&sender, token);
let request = zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", path, "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
request.receive_signal("Response").map_err(|e| e.to_string())
};
let response = |mut signals: zbus::blocking::proxy::SignalIterator<'static>| -> Result<HashMap<String, OwnedValue>, String> {
let message = signals.next().ok_or("the print portal went away")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = message.body().deserialize().map_err(|e| format!("odd portal reply: {e}"))?;
match code {
0 => Ok(results),
1 => Err("cancelled in the print dialog".into()),
_ => Err("the print dialog failed".into()),
}
};
let token = format!("photocraft_prepare_{}", nonce());
let signals = wait(&token)?;
let mut options: Dict<'_> = HashMap::new();
options.insert("handle_token", Value::from(token.as_str()));
let _: OwnedObjectPath =
proxy.call("PreparePrint", &("", title, as_dict(settings), as_dict(page_setup), options)).map_err(|e| format!("PreparePrint failed: {e}"))?;
let results = response(signals)?;
let prepared = results.get("token").and_then(|v| u32::try_from(v.clone()).ok()).ok_or("the print dialog gave no token")?;
let file = std::fs::File::open(pdf).map_err(|e| format!("cannot reopen the print file: {e}"))?;
let token = format!("photocraft_print_{}", nonce());
let signals = wait(&token)?;
let mut options: Dict<'_> = HashMap::new();
options.insert("handle_token", Value::from(token.as_str()));
options.insert("token", Value::from(prepared));
let fd = zbus::zvariant::Fd::from(std::os::fd::AsFd::as_fd(&file));
let _: OwnedObjectPath = proxy.call("Print", &("", title, fd, options)).map_err(|e| format!("Print failed: {e}"))?;
response(signals)?;
Ok("printed through the system print dialog".into())
}
#[cfg(target_os = "linux")]
fn nonce() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
t ^ N.fetch_add(1, Ordering::Relaxed)
}
/// The request object the portal answers on: the sender's unique name without its colon and
/// with dots as underscores, then the handle token.
fn request_path(unique_name: &str, token: &str) -> String {
format!("/org/freedesktop/portal/desktop/request/{}/{token}", unique_name.trim_start_matches(':').replace('.', "_"))
}
/// A value seeded into the dialog: GtkPrintSettings are strings; a GtkPageSetup's sizes are
/// doubles in millimetres (`gtk_paper_size_new_from_gvariant` ignores anything else).
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
enum Seed {
Text(String),
Mm(f64),
}
fn text(s: &str) -> Seed {
Seed::Text(s.to_string())
}
/// The standard paper sizes, as (PWG name, PPD name, width mm, height mm), portrait. GTK's
/// print settings take the PWG name; its page setup finds the size by the PPD name.
const PAPERS: [(&str, &str, f64, f64); 8] = [
("na_letter", "Letter", 215.9, 279.4),
("na_legal", "Legal", 215.9, 355.6),
("na_ledger", "Ledger", 279.4, 431.8),
("iso_a3", "A3", 297.0, 420.0),
("iso_a4", "A4", 210.0, 297.0),
("iso_a5", "A5", 148.0, 210.0),
("na_index-4x6", "4x6", 101.6, 152.4),
("na_5x7", "5x7", 127.0, 177.8),
];
const MM_PER_PT: f64 = 25.4 / 72.0;
type Paper = (&'static str, &'static str, f64, f64);
/// The job's paper: its row in `PAPERS` when it is a standard size, and the portrait size in
/// millimetres.
fn paper(paper_pt: (f64, f64)) -> (Option<&'static Paper>, f64, f64) {
let (w, h) = (paper_pt.0.min(paper_pt.1) * MM_PER_PT, paper_pt.0.max(paper_pt.1) * MM_PER_PT);
let row = PAPERS.iter().find(|(_, _, pw, ph)| (pw - w).abs() < 1.0 && (ph - h).abs() < 1.0);
(row, w, h)
}
fn orientation(landscape: bool) -> Seed {
text(if landscape { "landscape" } else { "portrait" })
}
/// `PreparePrint`'s settings (GtkPrintSettings keys, string values).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn settings_for(copies: u32, paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> {
let (row, w, h) = paper(paper_pt);
let mut v = vec![("n-copies", text(&copies.to_string())), ("orientation", orientation(landscape))];
match row {
Some((pwg, _, _, _)) => v.push(("paper-format", text(pwg))),
None => v.extend([("paper-width", text(&format!("{w:.2}"))), ("paper-height", text(&format!("{h:.2}")))]),
}
v
}
/// `PreparePrint`'s page setup (GtkPageSetup keys): the size in millimetres, found by its PPD
/// name when it has one, else a custom size named by its dimensions.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn page_setup_for(paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> {
let (row, w, h) = paper(paper_pt);
let mut v = vec![("Orientation", orientation(landscape)), ("Width", Seed::Mm(w)), ("Height", Seed::Mm(h))];
match row {
Some((_, ppd, _, _)) => v.push(("PPDName", text(ppd))),
None => v.push(("Name", text(&format!("custom_{w:.0}x{h:.0}mm")))),
}
v
}
#[cfg(target_os = "linux")]
fn settings(job: &PrintJob) -> Vec<(&'static str, Seed)> {
settings_for(job.copies, job.paper, job.landscape)
}
#[cfg(target_os = "linux")]
fn page_setup(job: &PrintJob) -> Vec<(&'static str, Seed)> {
page_setup_for(job.paper, job.landscape)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_path_follows_the_portal_rule() {
assert_eq!(request_path(":1.42", "photocraft_prepare_7"), "/org/freedesktop/portal/desktop/request/1_42/photocraft_prepare_7");
}
#[test]
fn standard_papers_get_their_pwg_and_ppd_names_and_custom_ones_their_size() {
// A4 landscape as the engine lays it out: width > height, still A4.
let s = settings_for(2, (841.89, 595.28), true);
assert_eq!(s, vec![("n-copies", text("2")), ("orientation", text("landscape")), ("paper-format", text("iso_a4"))]);
let p = page_setup_for((841.89, 595.28), true);
assert_eq!(p[0], ("Orientation", text("landscape")));
let Seed::Mm(w) = p[1].1 else { panic!("width is millimetres") };
let Seed::Mm(h) = p[2].1 else { panic!("height is millimetres") };
assert!((w - 210.0).abs() < 0.01 && (h - 297.0).abs() < 0.01, "{w} x {h}");
assert_eq!(p[3], ("PPDName", text("A4")));
assert_eq!(settings_for(1, (612.0, 792.0), false)[2], ("paper-format", text("na_letter")));
assert_eq!(page_setup_for((612.0, 792.0), false)[3], ("PPDName", text("Letter")));
assert_eq!(settings_for(1, (288.0, 432.0), false)[2], ("paper-format", text("na_index-4x6")));
// 100 x 200 mm is nobody's standard.
let custom = settings_for(1, (283.46, 566.93), false);
assert_eq!(&custom[2..], &[("paper-width", text("100.00")), ("paper-height", text("200.00"))]);
assert_eq!(page_setup_for((283.46, 566.93), false)[3], ("Name", text("custom_100x200mm")));
}
}
+351 -116
View File
@@ -1,12 +1,10 @@
//! Native platform services: file dialogs (rfd), filesystem, codecs.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
use photocraft_doc::Document;
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -17,12 +15,13 @@ use std::sync::Arc;
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "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",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase",
"kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -36,16 +35,50 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
}
/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder
/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of
/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native
/// picker defaults to another filter.
const OPEN_FILTERS: &[(&str, &[&str])] = &[
("PhotoCraft", &["pcraft"]),
("Photoshop", &["psd", "psb", "psdt"]),
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
("Krita", &["kra", "krz"]),
("GIMP", &["xcf", "xcfgz", "gz"]),
("OpenRaster", &["ora"]),
("Paint.NET", &["pdn"]),
("SVG", SVG_EXTS),
("PNG", &["png"]),
("JPEG", &["jpg", "jpeg"]),
("HEIF", &["heic", "heif", "hif"]),
("TIFF", &["tif", "tiff"]),
("WebP", &["webp"]),
("GIF", &["gif"]),
("BMP", &["bmp"]),
("Targa", &["tga"]),
("ICO", &["ico"]),
("QOI", &["qoi"]),
("PCX", &["pcx"]),
("DDS", &["dds"]),
("XPM", &["xpm"]),
("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]),
("OpenEXR", &["exr"]),
("Radiance HDR", &["hdr"]),
("FITS", &["fits", "fit", "fts"]),
("DPX and Cineon", &["dpx", "cin"]),
("Amiga IFF", &["iff", "lbm", "ilbm"]),
("DICOM", &["dcm", "dicom", "dc3"]),
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Swatches", &["aco", "act", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
vec![
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra", "krz"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS)))
.chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts))))
.collect()
}
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
@@ -68,11 +101,14 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
("Cineon", &["cin"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
/// never swaps their extension for a document format's.
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])];
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])];
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
@@ -160,17 +196,14 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
let dialog = if let Some(exts) = extensions {
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
} else {
open_filters()
.into_iter()
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
};
if multiple {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) })
} else {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) })
}
}
FileDialogRequest::Folder { initial_dir } => {
@@ -178,7 +211,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) })
}
FileDialogRequest::Save { suggested } => {
let filters = save_filters(&suggested);
@@ -213,10 +246,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
.map_err(|e| e.to_string())
.and_then(|conn| crate::portal_save::save_as(&conn, req));
match portal {
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)),
Err(e) => {
log::info!("portal save dialog unavailable ({e}), using rfd");
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo))
}
}
})
@@ -225,7 +258,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
{
let _ = filters;
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) })
}
}
};
@@ -239,12 +272,26 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
}
}
fn path_of(file: &rfd::FileHandle) -> String {
file.path().to_string_lossy().into_owned()
/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid
/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths
/// are to be written to.
fn answer<'a>(paths: impl IntoIterator<Item = &'a Path>, saving: bool, make: impl FnOnce(Vec<String>) -> FileDialogAnswer) -> FileDialogAnswer {
match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() {
Ok(paths) => make(paths),
Err(e) => FileDialogAnswer::Unavailable(e),
}
}
/// [`answer`] for a dialog that picks one path.
fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer {
match photocraft_ui_egui::file_open::path_text(path, saving) {
Ok(path) => make(path),
Err(e) => FileDialogAnswer::Unavailable(e),
}
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
fn block_on<T>(future: impl Future<Output = T>) -> T {
pub(crate) fn block_on<T>(future: impl Future<Output = T>) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
@@ -265,7 +312,7 @@ fn block_on<T>(future: impl Future<Output = T>) -> T {
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it
/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`].
pub fn config_dir() -> Option<PathBuf> {
crate::app_dirs::config_dir()
}
@@ -280,8 +327,9 @@ pub fn presets_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Presets"))
}
/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay
/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]).
/// Crash-recovery autosaves: under the settings directory, except where that is synced with the
/// settings: the Windows roaming profile and the Linux config folder (see
/// [`crate::app_dirs::resolve_recovery`]).
fn recovery_dir() -> Option<PathBuf> {
crate::app_dirs::recovery_dir()
}
@@ -298,6 +346,44 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path
/// on Windows (where it holds the name's letter case as stored). False when either can't be read.
fn same_file(a: &Path, b: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(a), std::fs::metadata(b)) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(not(unix))]
{
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
}
/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file
/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a
/// file system that ignores letter case is the same entry; a hard link is another entry, which a
/// save replaces while the first name keeps the old file. False when the folder can't be read.
fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool {
let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf();
if !same_file(a, b) || !same_file(&folder(a), &folder(b)) {
return false;
}
let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false };
if x == y {
return true;
}
let Ok(entries) = std::fs::read_dir(folder(a)) else { return false };
let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect();
!(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y))
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
settings.io_options()
}
@@ -355,12 +441,13 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
fn recovery_services(dir: Option<PathBuf>) -> Services {
let recover_dir = dir.clone();
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
let (s1, s2, s4, s5) = (store.clone(), store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
})),
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
flush_autosaves: Some(Box::new(move || with_store(&s5, RecoveryStore::flush).unwrap_or_default())),
discard_autosave: Some(Box::new(move |id: u64| {
let _ = with_store(&s2, |s| s.discard(id));
})),
@@ -383,6 +470,17 @@ fn recovery_services(dir: Option<PathBuf>) -> Services {
}
}
/// The OS clipboard read and write run under the crash guard, like Open and Export (#7582): a
/// panic in arboard, or in decoding a copied file, becomes the paste's (or copy's) error and the
/// app keeps its documents.
fn guarded_clipboard_get(mut read: impl FnMut() -> Result<Option<(u32, u32, Vec<u8>)>, String> + 'static) -> photocraft_ui_egui::ClipboardGetFn {
Box::new(move || crate::crash_guard::guard("Paste", &mut read))
}
fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn {
Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px)))
}
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
@@ -464,6 +562,7 @@ fn shell_word(s: &str) -> String {
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
@@ -494,9 +593,9 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}) as photocraft_ui_egui::AutomationCommandFn
});
Services {
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
crate::crash_guard::guard("Open", || {
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())
photocraft_io::import_with_options(name, bytes, opts).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| {
@@ -511,6 +610,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let meta = std::fs::metadata(path).ok()?;
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
})),
same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))),
automation_read,
automation_write,
automation_open_pcraft,
@@ -526,7 +626,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
beep: Some(platform_beep),
clipboard_set_image: Some({
let clip = clip.clone();
Box::new(move |w: u32, h: u32, px: &[u8]| {
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
@@ -537,26 +637,47 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}),
clipboard_get_image: Some({
let clip = clip.clone();
Box::new(move || {
let mut slot = clip.try_borrow_mut().ok()?;
let unreadable = clip_unreadable.clone();
guarded_clipboard_get(move || {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().ok()?),
None => {
// arboard opens only when some backend works. In a Flatpak on Wayland none
// does (#5031): the sandbox hides the data-control protocol from it, and
// `--socket=fallback-x11` gives no X server while a compositor runs.
let opened = arboard::Clipboard::new();
unreadable.set(opened.is_err());
slot.insert(opened.map_err(|e| e.to_string())?)
}
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
match cb.get().file_list() {
Ok(paths) => {
if let Some(img) = image_from_files(&paths) {
return Ok(Some(img));
}
}
Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
Err(_) => {}
}
if let Ok(img) = cb.get_image() {
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
match cb.get_image() {
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
Err(error) => {
#[cfg(target_os = "linux")]
if let Some(image) = wayland_clipboard_image() {
return Ok(Some(image));
}
match error {
arboard::Error::ContentNotAvailable => Ok(None),
_ => Err(error.to_string()),
}
}
}
#[cfg(target_os = "linux")]
return wayland_clipboard_image();
#[cfg(not(target_os = "linux"))]
None
})
}),
clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
@@ -581,77 +702,19 @@ fn platform_beep() {
{
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
}
#[cfg(not(target_os = "windows"))]
// The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal
// bell below is silent for an app started from the Finder or the Dock.
#[cfg(target_os = "macos")]
objc2_app_kit::NSBeep();
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
// No beep API among this app's other platforms' dependencies: write the terminal bell
// (audible when launched from a terminal, which is the same cue macOS terminals use).
// No beep API among the other platforms' dependencies: write the terminal bell (audible
// when launched from a terminal).
use std::io::Write as _;
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
}
}
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
#[allow(dead_code)]
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?;
let (w, h) = (img.width(), img.height());
let depth = match img.sample_type() {
CS::U8 => SampleType::U8,
CS::U16 => SampleType::U16,
_ => SampleType::F32,
};
let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA);
let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA);
let mode = if gray {
ColorMode::Grayscale
} else if cmyk {
ColorMode::Cmyk
} else {
ColorMode::Rgb
};
let target = match mode {
ColorMode::Grayscale => ChannelLayout::GrayA,
ColorMode::Cmyk => ChannelLayout::CmykA,
_ => ChannelLayout::Rgba,
};
let sample = match depth {
SampleType::U8 => CS::U8,
SampleType::U16 => CS::U16,
SampleType::F32 => CS::F32,
};
let conv = img.convert(target, sample);
let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string());
let mut doc = Document::new(stem, Size::new(w, h), mode, depth);
doc.icc_profile = img.icc.clone().map(std::sync::Arc::new);
if let Some((x, _)) = img.meta.dpi {
doc.resolution_dpi = x;
}
let mut layer = Layer::raster("Background", doc.pixel_format());
let data = conv.to_normalized();
layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data);
doc.layers.push(layer);
Ok(doc)
}
#[allow(dead_code)]
pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?;
let buf = photocraft_compose::flatten(doc);
let (w, h) = (buf.rect.width(), buf.rect.height());
let data: Vec<f32> = buf.px.iter().flat_map(|p| *p).collect();
let img = match doc.depth {
SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels),
SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::<Vec<_>>()),
SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data),
}
.map_err(|e| e.to_string())?;
let img = match &doc.icc_profile {
Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())),
_ => img,
};
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -660,6 +723,31 @@ mod tests {
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
/// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily
/// (Open would fail with "No such file", Save would write a second, renamed file).
#[test]
fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() {
#[cfg(unix)]
let bad = {
use std::os::unix::ffi::OsStrExt;
std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png"))
};
#[cfg(windows)]
let bad = {
use std::os::windows::ffi::OsStringExt;
let mut wide: Vec<u16> = "img".encode_utf16().collect();
wide.push(0xD800);
wide.extend(".png".encode_utf16());
std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide))
};
let good = std::env::temp_dir().join("ok.png");
let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo);
assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name")));
assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap()));
let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths);
assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again")));
}
/// #6121: the file type picked in the save dialog gives the name its extension where the
/// dialog left the one it opened with, and only there.
#[test]
@@ -695,6 +783,57 @@ mod tests {
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
}
/// Regression using the real clipboard on an isolated Windows CI runner.
#[cfg(target_os = "windows")]
#[test]
#[ignore = "requires exclusive use of the Windows clipboard"]
fn busy_native_clipboard_preserves_copied_shape() {
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
let mut native = native(None);
let services = Services {
clipboard_set_image: native.clipboard_set_image.take(),
clipboard_get_image: native.clipboard_get_image.take(),
..Default::default()
};
let mut app = PhotocraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded");
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
// Another thread holds the OS clipboard open beyond arboard's bounded retry window.
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let holder = std::thread::spawn(move || {
// A non-null window distinguishes this owner from arboard's null-window opens.
let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap();
ready_tx.send(()).unwrap();
release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
drop(guard);
});
ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
let contention = arboard::Clipboard::new().unwrap().get_image().err();
let first = app.run(id, json!({}));
release_tx.send(()).unwrap();
holder.join().unwrap();
assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}");
eprintln!("paste while clipboard occupied: {first:?}");
assert!(first.is_err(), "report contention rather than a successful no-op");
assert!(
app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()),
"a temporary OS read failure must not discard the copied editable shape"
);
app.run(id, json!({})).unwrap();
let state = app.session.active().unwrap();
assert!(
matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)),
"retry keeps the shape editable"
);
}
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
@@ -726,15 +865,42 @@ mod tests {
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS));
let psd_filter = open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(psd_filter, open_filter_extensions(&["psd", "psb", "psdt"]));
assert!(psd_filter.iter().any(|glob| matches(glob, "psd")) && psd_filter.iter().any(|glob| matches(glob, "PSD")));
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
/// #6337: after All Formats, every format File › Open reads has its own filter, and no filter
/// offers an extension All Formats doesn't.
#[test]
fn every_open_format_has_its_own_filter() {
let filters = open_filters();
assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first");
let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect();
let total = named.len();
named.sort_unstable();
named.dedup();
assert_eq!(named.len(), total, "an extension is listed under two filters");
let mut all = OPEN_EXTS.to_vec();
all.sort_unstable();
assert_eq!(named, all, "the named filters cover All Formats exactly");
let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect();
for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] {
assert!(names.contains(&name), "{name}");
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
#[test]
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()]));
assert_eq!(
open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts),
Some(vec!["psd".to_string(), "psb".to_string(), "psdt".to_string()])
);
}
#[test]
@@ -844,7 +1010,7 @@ mod tests {
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp"] {
for ext in ["ico", "gif", "bmp", "dds"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
@@ -888,6 +1054,50 @@ mod tests {
assert_eq!(dialog_file_name("", true), None);
}
/// A name in another letter case reaches the same file where the file system ignores case
/// (Windows, and macOS by default), and only there. Two files with the same contents are two.
#[test]
fn same_file_follows_the_file_system() {
let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD"));
std::fs::write(&cat, b"same").unwrap();
std::fs::write(&dog, b"same").unwrap();
assert!(same_file(&cat, &cat));
assert!(!same_file(&cat, &dog));
assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored");
assert!(!same_file(&dir.join("missing.psd"), &cat));
std::fs::remove_dir_all(&dir).unwrap();
}
/// A hard link is another entry in the folder, though it reaches the same file: a save to it
/// replaces that entry and leaves the other name on the old file. Another spelling of a name,
/// where letter case is ignored, is the same entry.
#[test]
fn a_hard_link_is_another_entry() {
let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id()));
std::fs::create_dir_all(dir.join("other")).unwrap();
let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd"));
std::fs::write(&cat, b"cat").unwrap();
std::fs::hard_link(&cat, &bare).unwrap();
std::fs::hard_link(&cat, &other).unwrap();
// Where both names reach one file (one inode on Unix), the folder still lists both.
let one_file = |_: &Path, _: &Path| true;
assert!(!same_entry(&bare, &cat, one_file));
assert!(!same_entry(&bare, &cat, same_file));
assert!(!same_entry(&other, &cat, same_file), "another folder");
assert!(same_entry(&cat, &cat, one_file));
assert!(same_entry(&cat, &cat, same_file));
if upper.exists() {
assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored");
} else {
std::fs::hard_link(&cat, &upper).unwrap();
assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart");
}
assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
@@ -1165,4 +1375,29 @@ mod tests {
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_files(&[bin]).is_none());
}
/// #7582: a panic while reading the OS clipboard (in arboard, or decoding a copied file) is
/// the paste's error in the status bar, not a crash: the document stays and the next paste
/// works. The same for a panic while writing it.
#[test]
fn a_panicking_clipboard_read_is_reported_not_a_crash() {
let panics = Rc::new(Cell::new(true));
let p = panics.clone();
let get = guarded_clipboard_get(move || if p.get() { panic!("clipboard decoder bug") } else { Ok(Some((2, 2, [0u8, 0, 255, 255].repeat(4)))) });
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(get), ..Default::default() });
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
let layers = app.session.active().unwrap().doc.layers.len();
let error = app.run("edit.paste", json!({})).unwrap_err();
assert!(error.contains("Paste failed"), "{error}");
assert!(app.ui.status_error && app.ui.status.contains("Paste failed"), "{}", app.ui.status);
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "the document is unchanged");
panics.set(false);
app.run("edit.paste", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the next paste works");
let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug"));
assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed"));
}
}
@@ -1,5 +1,6 @@
use super::*;
use photocraft_color::Color;
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_doc::Size;
use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery};
use std::sync::atomic::{AtomicUsize, Ordering};
+408 -35
View File
@@ -3,8 +3,9 @@
//! copy of the program.
//!
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
//! drops it when the process ends, crash included), listens on a loopback port (inside a Flatpak
//! sandbox, on a Unix socket: see [`transport`]) and writes where it listens plus a fresh 256-bit
//! token to `instance.json`. A later launch that finds the lock taken sends its
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
//! brings the window forward. Explorer starts one process per file when several are opened at once;
//! the lock decides the race, so they all end up as tabs in one window.
@@ -26,7 +27,11 @@
use std::fs::{File, OpenOptions};
use std::io::{BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
#[cfg(unix)]
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{SyncSender, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
@@ -38,6 +43,9 @@ use serde_json::{Value, json};
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// The Unix socket a sandboxed primary listens on, in the app's runtime directory.
#[cfg(unix)]
const SOCKET_FILE: &str = "instance.sock";
/// More paths than this in one request is not a file manager.
const MAX_PATHS: usize = 4096;
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
@@ -59,6 +67,45 @@ pub struct Timing {
/// Generous enough for a primary that is still opening its window.
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
/// How the primary listens for later launches.
#[derive(Clone, Debug, PartialEq)]
pub enum Transport {
/// A loopback port (the default): private to the machine, reachable by every process on it.
Loopback,
/// A Unix socket at this path. Inside a Flatpak sandbox a loopback port is not enough: each
/// `flatpak run` has its own network namespace, so the second sandbox's `127.0.0.1` never
/// reaches the first (#1540). `$XDG_RUNTIME_DIR/app/<app id>/` is the same directory in every
/// sandbox of the app, and the user's alone (0700).
#[cfg(unix)]
Socket(PathBuf),
}
/// The transport for this process: a Unix socket when running inside a Flatpak (`FLATPAK_ID` is
/// set and `XDG_RUNTIME_DIR` names the runtime directory), else a loopback port.
pub fn transport() -> Transport {
#[cfg(unix)]
if let Some(path) = sandbox_socket(std::env::var_os("FLATPAK_ID").as_deref(), std::env::var_os("XDG_RUNTIME_DIR").as_deref()) {
return Transport::Socket(path);
}
Transport::Loopback
}
/// `$XDG_RUNTIME_DIR/app/<FLATPAK_ID>/instance.sock`, when both are set.
#[cfg(unix)]
fn sandbox_socket(flatpak_id: Option<&std::ffi::OsStr>, runtime_dir: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
let id = flatpak_id.filter(|v| !v.is_empty())?;
let dir = runtime_dir.filter(|v| !v.is_empty())?;
Some(Path::new(dir).join("app").join(id).join(SOCKET_FILE))
}
/// Where the running primary listens, as `instance.json` records it.
#[derive(Clone, Debug, PartialEq)]
enum Endpoint {
Port(u16),
#[cfg(unix)]
Socket(PathBuf),
}
/// Outcome of [`claim`].
pub enum Launch {
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
@@ -82,10 +129,71 @@ pub struct LaunchContext<'a> {
pub control: bool,
/// Paths that aren't valid Unicode: this launch reports them.
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
/// Options that only apply to the process they're given to (`--safe-gpu`, …), including the
/// ones this launch's environment sets (see [`env_launch_options`]).
pub launch_options: &'a [&'static str],
}
/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for
/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for
/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this
/// process alone.
const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [
("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on),
("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on),
("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set),
("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set),
];
/// Whether a variable's value asks this launch for its own option. Each rule matches how that
/// variable is read where it takes effect, so a value ignored there keeps no process here either.
type ValueRule = fn(&str) -> bool;
/// A value `presentation::parse` accepts, handed over as written: it trims, treats empty as unset and
/// warns and ignores anything it does not recognise, so an unrecognised `PHOTOCRAFT_PRESENT_MODE`
/// changes nothing there and so needs no process of its own here.
fn present_mode_is_on(value: &str) -> bool {
crate::presentation::parse(Some(value), None).0.mode.is_some()
}
/// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`.
fn frame_latency_is_on(value: &str) -> bool {
crate::presentation::parse(None, Some(value)).0.latency.is_some()
}
/// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the
/// rest is a stderr summary or a CSV path. It trims, so `" 0 "` is off there and off here.
fn stroke_trace_is_on(value: &str) -> bool {
!matches!(value.trim(), "" | "0" | "off" | "false")
}
/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is
/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does
/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too.
fn flag_is_on(value: &str) -> bool {
!value.is_empty() && value != "0"
}
/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty
/// root is read as no root unless `--control` is given, and that flag keeps its own process already.
fn path_is_set(value: &str) -> bool {
!value.is_empty()
}
/// The per-process options this launch's environment sets, named as the flags they stand for. A
/// launch that sets only variables would otherwise hand its files to the running instance, which
/// never sees the override, and exit.
pub fn env_launch_options() -> Vec<&'static str> {
env_launch_options_from(|name| std::env::var_os(name))
}
/// [`env_launch_options`] with the reader given, so the rule is testable without the environment.
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<&'static str> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect()
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
pub fn skip_reason(c: &LaunchContext) -> Option<String> {
if !c.per_user_settings {
@@ -110,6 +218,10 @@ pub fn skip_reason(c: &LaunchContext) -> Option<String> {
pub struct Primary {
lock: Mutex<Option<File>>,
shared: Arc<Shared>,
/// The socket file to remove on the first shutdown (a Unix socket transport only); taken
/// then, so the second call (`Drop`) can't remove the socket the next primary has bound.
#[cfg(unix)]
socket: Mutex<Option<PathBuf>>,
}
struct Shared {
@@ -151,6 +263,10 @@ impl Primary {
self.shared.closed.store(true, Ordering::Release);
// Dropping the senders refuses every connection still waiting for the UI.
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
#[cfg(unix)]
if let Some(path) = self.socket.lock().unwrap_or_else(PoisonError::into_inner).take() {
let _ = std::fs::remove_file(path);
}
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
}
}
@@ -211,9 +327,15 @@ pub fn claim(dir: &Path, paths: &[String]) -> Launch {
claim_with(dir, paths, TIMING)
}
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
/// (free once a quitting or crashed primary is gone) and then the running primary.
/// [`claim`] with explicit timeouts, over the transport this process would use ([`transport`]).
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
claim_in(dir, paths, timing, &transport())
}
/// [`claim`] over `transport`. Until `timing.handoff` runs out, each round tries the lock (free
/// once a quitting or crashed primary is gone) and then the running primary, wherever
/// `instance.json` says it listens.
pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transport) -> Launch {
if let Err(e) = std::fs::create_dir_all(dir) {
return Launch::Standalone(format!("{}: {e}", dir.display()));
}
@@ -227,7 +349,7 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
loop {
match lock.try_lock() {
Ok(()) => {
return match serve(dir, lock, timing) {
return match serve(dir, lock, timing, transport) {
Ok(primary) => Launch::Primary(primary),
Err(e) => Launch::Standalone(e),
};
@@ -235,8 +357,8 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
}
if let Some((port, token)) = read_info(dir) {
match send(port, &token, paths, deadline) {
if let Some((endpoint, token)) = read_info(dir) {
match send(&endpoint, &token, paths, deadline) {
Ok(()) => return Launch::Forwarded,
Err(e) => last = e,
}
@@ -248,11 +370,15 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
}
}
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Result<Primary, String> {
let (listener, endpoint) = Listener::bind(transport)?;
let token = generate_token().map_err(|e| e.to_string())?;
write_info(dir, port, &token)?;
write_info(dir, &endpoint, &token)?;
#[cfg(unix)]
let socket = match &endpoint {
Endpoint::Socket(path) => Some(path.clone()),
Endpoint::Port(_) => None,
};
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
let listening = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
@@ -260,7 +386,7 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let clients = Arc::new(AtomicUsize::new(0));
loop {
let stream = match listener.accept() {
Ok((stream, _)) => stream,
Ok(stream) => stream,
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
@@ -288,11 +414,117 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
}
});
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
Ok(Primary {
lock: Mutex::new(Some(lock)),
shared,
#[cfg(unix)]
socket: Mutex::new(socket),
})
}
/// The primary's listener on either transport.
enum Listener {
Tcp(TcpListener),
#[cfg(unix)]
Unix(UnixListener),
}
impl Listener {
fn bind(transport: &Transport) -> Result<(Listener, Endpoint), String> {
match transport {
Transport::Loopback => {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
Ok((Listener::Tcp(listener), Endpoint::Port(port)))
}
#[cfg(unix)]
Transport::Socket(path) => {
let at = |e: std::io::Error| format!("{}: {e}", path.display());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(at)?;
}
// This process holds the lock, so a file already there is a crashed primary's.
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path).map_err(at)?;
// The runtime directory is the user's alone already; keep the socket so too.
std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(at)?;
Ok((Listener::Unix(listener), Endpoint::Socket(path.clone())))
}
}
}
fn accept(&self) -> std::io::Result<Stream> {
match self {
Listener::Tcp(l) => l.accept().map(|(s, _)| Stream::Tcp(s)),
#[cfg(unix)]
Listener::Unix(l) => l.accept().map(|(s, _)| Stream::Unix(s)),
}
}
}
/// One connection, on either transport.
enum Stream {
Tcp(TcpStream),
#[cfg(unix)]
Unix(UnixStream),
}
impl Stream {
/// Connect to the primary; `timeout` bounds the loopback connect (a Unix connect is immediate).
fn connect(endpoint: &Endpoint, timeout: Duration) -> std::io::Result<Stream> {
match endpoint {
Endpoint::Port(port) => TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, *port)), timeout).map(Stream::Tcp),
#[cfg(unix)]
Endpoint::Socket(path) => UnixStream::connect(path).map(Stream::Unix),
}
}
fn set_read_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.set_read_timeout(dur),
#[cfg(unix)]
Stream::Unix(s) => s.set_read_timeout(dur),
}
}
fn set_write_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.set_write_timeout(dur),
#[cfg(unix)]
Stream::Unix(s) => s.set_write_timeout(dur),
}
}
/// One read through a shared reference (both socket types read through `&self`).
fn read_once(&self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Stream::Tcp(s) => (&*s).read(buf),
#[cfg(unix)]
Stream::Unix(s) => (&*s).read(buf),
}
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Stream::Tcp(s) => s.write(buf),
#[cfg(unix)]
Stream::Unix(s) => s.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.flush(),
#[cfg(unix)]
Stream::Unix(s) => s.flush(),
}
}
}
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
fn serve_client(stream: Stream, token: &str, shared: &Shared, timing: Timing) {
let taken = match read_request(&stream, timing.request) {
Some(line) => match parse_request(&line, token) {
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
@@ -308,7 +540,7 @@ fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing)
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
struct Deadline<'a> {
stream: &'a TcpStream,
stream: &'a Stream,
until: Instant,
}
@@ -317,25 +549,30 @@ impl Read for Deadline<'_> {
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
self.stream.set_read_timeout(Some(left))?;
let mut stream = self.stream;
stream.read(buf)
self.stream.read_once(buf)
}
}
/// The request line, if it arrives complete within `total`.
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
fn read_request(stream: &Stream, total: Duration) -> Option<String> {
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
let mut line = String::new();
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
}
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
/// Endpoint and token, written to a temporary file and renamed so a reader never sees half of it.
/// On Unix the file is the user's alone (0600): another local user who read the token could make
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String> {
let path = dir.join(INFO_FILE);
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
let mut body = json!({"token": token, "pid": std::process::id()});
match endpoint {
Endpoint::Port(port) => body["port"] = json!(port),
#[cfg(unix)]
Endpoint::Socket(socket) => body["socket"] = json!(socket.to_string_lossy()),
}
let body = body.to_string();
// A leftover from a crash could carry wider permissions; the mode applies only on create.
let _ = std::fs::remove_file(&tmp);
let mut options = OpenOptions::new();
@@ -349,10 +586,15 @@ fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
})
}
fn read_info(dir: &Path) -> Option<(u16, String)> {
fn read_info(dir: &Path) -> Option<(Endpoint, String)> {
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
let token = v.get("token")?.as_str()?.to_string();
#[cfg(unix)]
if let Some(socket) = v.get("socket").and_then(Value::as_str) {
return Some((Endpoint::Socket(PathBuf::from(socket)), token));
}
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
Some((port, v.get("token")?.as_str()?.to_string()))
Some((Endpoint::Port(port), token))
}
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
@@ -370,11 +612,10 @@ fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
Some((paths, wait))
}
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
/// Offer `paths` to the primary at `endpoint`; `Ok` once its UI has taken them.
fn send(endpoint: &Endpoint, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
let mut stream = Stream::connect(endpoint, left).map_err(|e| e.to_string())?;
// The primary must answer before our deadline, so it waits a little less than we do.
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
@@ -435,6 +676,21 @@ mod tests {
}
}
/// A socket path short enough for every Unix (`sun_path` is about 100 bytes; macOS: 104).
#[cfg(unix)]
fn short_socket_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("pc-si-{}-{tag}", std::process::id())).join(SOCKET_FILE)
}
/// The loopback port the primary under `dir` listens on.
fn port_of(dir: &Path) -> u16 {
match read_info(dir).unwrap().0 {
Endpoint::Port(port) => port,
#[cfg(unix)]
Endpoint::Socket(path) => panic!("expected a loopback port, found {}", path.display()),
}
}
#[test]
fn second_launch_hands_its_paths_to_the_first() {
let dir = temp_dir("handoff");
@@ -496,7 +752,7 @@ mod tests {
// instance.json from a process that is gone; the lock is free.
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
let primary = primary(&dir);
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
assert!(read_info(&dir).is_some_and(|(endpoint, _)| endpoint != Endpoint::Port(1)));
drop(primary);
// Released with the handle, as on process exit.
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
@@ -516,10 +772,10 @@ mod tests {
primary.shutdown();
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
// After shutdown new connections are refused too.
let (port, token) = read_info(&dir).unwrap();
let (endpoint, token) = read_info(&dir).unwrap();
drop(primary);
let short = Instant::now() + Duration::from_millis(300);
assert!(send(port, &token, &["b.psd".into()], short).is_err());
assert!(send(&endpoint, &token, &["b.psd".into()], short).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -557,7 +813,7 @@ mod tests {
let dir = temp_dir("stalled");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let (port, _) = read_info(&dir).unwrap();
let port = port_of(&dir);
// Connects and never sends a line (each would otherwise hold the listener).
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
let started = Instant::now();
@@ -575,7 +831,7 @@ mod tests {
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
let dir = temp_dir("trickle");
let primary = primary(&dir);
let (port, _) = read_info(&dir).unwrap();
let port = port_of(&dir);
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let started = Instant::now();
let mut cut_off = false;
@@ -602,7 +858,7 @@ mod tests {
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, "{}").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
write_info(&dir, &Endpoint::Port(1), &generate_token().unwrap()).unwrap();
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
@@ -616,7 +872,7 @@ mod tests {
let held = File::create(dir.join(LOCK_FILE)).unwrap();
held.lock().unwrap();
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
write_info(&dir, &Endpoint::Port(closed), &generate_token().unwrap()).unwrap();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
drop(held);
let _ = std::fs::remove_dir_all(&dir);
@@ -645,4 +901,121 @@ mod tests {
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
}
/// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime
/// directory, which every sandbox of the app shares, with the same lock, token and reply.
#[cfg(unix)]
#[test]
fn a_sandboxed_second_launch_hands_its_paths_over_a_unix_socket() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("socket");
// Short: a Unix socket path is limited to about 100 bytes (macOS: 104).
let socket = short_socket_path("a");
let transport = Transport::Socket(socket.clone());
let primary = match claim_in(&dir, &[], FAST, &transport) {
Launch::Primary(p) => p,
_ => panic!("first launch should be primary"),
};
assert_eq!(std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, 0o600);
let info: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).unwrap()).unwrap();
assert_eq!(info.get("socket").and_then(Value::as_str), Some(socket.to_str().unwrap()));
assert!(info.get("port").is_none());
let mut ui = primary.connect(&egui::Context::default());
let paths = ["/home/a/one.psd".to_string()];
let (d, p, t) = (dir.clone(), paths.clone(), transport.clone());
let second = std::thread::spawn(move || claim_in(&d, &p, TIMING, &t));
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.to_vec())]);
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
// The token still gates the socket.
let (endpoint, _) = read_info(&dir).unwrap();
assert!(send(&endpoint, "not the token", &["x.psd".into()], Instant::now() + FAST.handoff).is_err());
primary.shutdown();
assert!(!socket.exists(), "shutdown removes the socket file");
// The next primary binds the same path; the old one's drop (a second shutdown) must leave it.
let next = match claim_in(&dir, &[], FAST, &transport) {
Launch::Primary(p) => p,
_ => panic!("the lock is free again"),
};
drop(primary);
assert!(socket.exists(), "a second shutdown doesn't remove the next primary's socket");
drop(next);
let _ = std::fs::remove_dir_all(&dir);
}
/// A socket file left by a crashed primary (and a stale `instance.json` naming it) is replaced.
#[cfg(unix)]
#[test]
fn a_crashed_primary_s_socket_file_does_not_block_the_next_one() {
use std::os::unix::fs::FileTypeExt;
let dir = temp_dir("stale-socket");
let socket = short_socket_path("b");
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(socket.parent().unwrap()).unwrap();
std::fs::write(&socket, b"not a socket").unwrap();
write_info(&dir, &Endpoint::Socket(socket.clone()), &generate_token().unwrap()).unwrap();
let primary = match claim_in(&dir, &["a.psd".into()], FAST, &Transport::Socket(socket.clone())) {
Launch::Primary(p) => p,
Launch::Forwarded => panic!("nothing was listening"),
Launch::Standalone(e) => panic!("{e}"),
};
assert!(std::fs::metadata(&socket).unwrap().file_type().is_socket());
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(socket.parent().unwrap());
}
/// The socket transport is chosen only inside a Flatpak with a runtime directory.
#[cfg(unix)]
#[test]
fn the_socket_transport_needs_a_flatpak_id_and_a_runtime_directory() {
use std::ffi::OsStr;
let (id, run) = (OsStr::new("ai.storyteller.photocraft"), OsStr::new("/run/user/1000"));
assert_eq!(sandbox_socket(Some(id), Some(run)), Some(PathBuf::from("/run/user/1000/app/ai.storyteller.photocraft/instance.sock")));
assert_eq!(sandbox_socket(None, Some(run)), None);
assert_eq!(sandbox_socket(Some(id), None), None);
assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None);
assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None);
}
/// A variable documented as the equivalent of a per-launch flag counts as that flag: a launch
/// that sets only variables has per-process options, so it keeps its own process rather than
/// handing its files to the running instance, which never sees the override.
#[test]
fn an_environment_override_counts_as_a_process_option() {
let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into()));
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]);
assert_eq!(
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]),
vec!["--stroke-trace", "--automation-write-root"]
);
// Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way
// it is read where it takes effect, not by one rule shared across all six.
assert!(with(&[]).is_empty());
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
// The trace is off at `off` and `false` as well, and a value the presentation parser rejects
// is ignored there, so neither keeps a process of its own here.
assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty());
assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty());
// Spaces are part of the value only where the reader keeps them: the trace and the two
// presentation values are trimmed where they take effect, the menu flag and a path are not.
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]);
assert_eq!(
with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]),
vec!["--in-window-menus", "--automation-write-root"]
);
// A whitespace-only menu value is not empty, so it enables the menus there and here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]);
// `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]);
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
new_instance_env: None,
control: false,
unreadable_paths: false,
launch_options: &[],
};
let reason = skip_reason(&LaunchContext { launch_options: &with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), ..base });
assert!(reason.as_deref().is_some_and(|r| r.contains("--present-mode")), "{reason:?}");
}
}
+29 -6
View File
@@ -1,10 +1,12 @@
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
//! barrel button on Windows (#5905).
//!
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
//! `WM_POINTER` pressure as touch force.
//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
//! button through a thread-local message hook beside winit.
//!
//! Wayland compositors give a pen only to clients that bind the tablet protocol
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
@@ -19,15 +21,36 @@ use photocraft_tablet::Sample;
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
/// A tablet sample as the UI's pen sample.
#[allow(dead_code)]
pub fn pen_sample(s: Sample) -> PenSample {
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
}
/// Writes tablet samples into `feed` (`None` = a mouse).
#[allow(dead_code)]
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
/// Install the Windows pointer message hook (main thread; keep the result until the event loop
/// returns). On failure this logs why: the pen barrel button is then missed.
#[cfg(target_os = "windows")]
pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
}
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
/// reports the logical secondary button (swapped buttons included) for the app's own window only.
#[cfg(target_os = "windows")]
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
Some(Box::new(photocraft_tablet::windows::is_barrel_button_down))
}
#[cfg(not(target_os = "windows"))]
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
None
}
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
/// returns. On failure this logs why: pens then paint like a mouse.
+117 -4
View File
@@ -20,6 +20,11 @@
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
//!
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
//!
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
@@ -29,7 +34,20 @@ pub use platform::service;
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::WindowFrameFn;
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
use winsafe::prelude::Handle;
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
type Rect = (i32, i32, i32, i32);
/// A monitor edge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Edge {
Left,
Top,
Right,
Bottom,
}
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
/// system title bar (the window is decorated) or its handle can't be found.
@@ -99,8 +117,11 @@ 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))
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
// covers the whole monitor, which a trimmed maximize no longer does.
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
else {
return;
};
@@ -153,6 +174,69 @@ mod platform {
(corrected != rect).then_some(corrected)
}
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
fn covers(rect: Rect, monitor: Rect) -> bool {
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
}
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
let mut edges = Vec::new();
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
let mut after: Option<HWND> = None;
// One taskbar per monitor; the bound only guards against a misbehaving shell.
for _ in 0..64 {
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
break;
};
if let Ok(r) = tray.GetWindowRect() {
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
}
after = Some(tray);
}
}
edges
}
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
return None;
}
// Doubled centre and distances, so nothing rounds.
let (cx, cy) = (tl + tr, tt + tb);
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
.into_iter()
.min_by_key(|&(_, distance)| distance)
.map(|(edge, _)| edge)
}
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
/// `None` when there is nothing to trim.
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
if !covers(rect, monitor) {
return None;
}
let (mut l, mut t, mut r, mut b) = rect;
for edge in taskbars {
match edge {
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
_ => {}
}
}
let trimmed = (l, t, r, b);
(l < r && t < b && trimmed != rect).then_some(trimmed)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
/// keep working, as they did with winit 0.27's undecorated windows.
@@ -162,7 +246,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::{borderless, clamped_rect};
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
use winsafe::co;
#[test]
@@ -221,6 +305,35 @@ mod platform {
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");
}
#[test]
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
// #4845: an auto-hidden taskbar reserves no work area.
let monitor = (0, 0, 1920, 1080);
let rect = (-8, -8, 1928, 1088);
for (edge, expected) in
[(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))]
{
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
}
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
// A taskbar that reserves its work area is the clamp's job.
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
}
#[test]
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
let monitor = (0, 0, 1920, 1080);
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
}
}
}
@@ -0,0 +1,95 @@
//! `photocraft --help` and unknown options must not open the window (#6431): before the fix,
//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as
//! `--safegpu` launched the GUI and logged "Couldn't open --help".
//!
//! These run the real binary, and every case must exit before the window opens: Windows and
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the
//! other usage errors, so stderr shows whether those arguments were taken as unknown options.
/// Run `photocraft` with `args` in an isolated config dir; return the exit
/// status, stdout and stderr.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned())
}
#[test]
fn help_prints_usage_and_exits_0() {
for flag in ["--help", "-h"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}");
for option in ["--safe-gpu", "--new-instance", "--control <port>", "--version", "--"] {
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
}
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
}
}
#[test]
fn help_wins_over_later_arguments() {
let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}");
}
#[test]
fn unknown_options_exit_2_naming_them() {
for flag in ["--safegpu", "-x", "--help=yes"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}");
assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}");
assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}");
assert!(stdout.is_empty(), "stdout: {stdout}");
}
}
#[test]
fn unknown_option_errors_are_reported_with_control_port_errors() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}");
}
#[test]
fn arguments_after_double_dash_are_files() {
// `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage
// nor is an unknown option; only the bad port is reported.
let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stdout.contains("Usage:"), "stdout: {stdout}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
#[test]
fn macos_process_serial_number_is_still_ignored() {
// Old macOS passes `-psn_<n>` when launching from Finder; it is neither a file nor an error.
let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
#[test]
fn new_instance_is_a_known_option() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, linked into one test binary.
mod command_line_usage;
mod control_exit_code;
mod startup_without_display;
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 20h18" />
<rect x="5" y="4" width="5" height="12" rx="1" />
<rect x="14" y="9" width="5" height="7" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M12 3v18" />
<rect x="5" y="5" width="14" height="5" rx="1" />
<rect x="8" y="14" width="8" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3v18" />
<rect x="8" y="5" width="12" height="5" rx="1" />
<rect x="8" y="14" width="7" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M20 3v18" />
<rect x="4" y="5" width="12" height="5" rx="1" />
<rect x="9" y="14" width="7" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4h18" />
<rect x="5" y="8" width="5" height="12" rx="1" />
<rect x="14" y="8" width="5" height="7" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 12h18" />
<rect x="5" y="5" width="5" height="14" rx="1" />
<rect x="14" y="8" width="5" height="8" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+19
View File
@@ -0,0 +1,19 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 7h16" />
<rect x="7" y="2" width="10" height="3" rx="1" />
<path d="M4 14h16" />
<rect x="7" y="9" width="10" height="3" rx="1" />
<path d="M4 21h16" />
<rect x="7" y="16" width="10" height="3" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 516 B

+22
View File
@@ -0,0 +1,22 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<rect x="2" y="6" width="4" height="12" rx="1" />
<path d="M4 2v2" />
<path d="M4 20v2" />
<rect x="10" y="6" width="4" height="12" rx="1" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<rect x="18" y="6" width="4" height="12" rx="1" />
<path d="M20 2v2" />
<path d="M20 20v2" />
</svg>

After

Width:  |  Height:  |  Size: 585 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4v16" />
<path d="M21 4v16" />
<rect x="9" y="7" width="6" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

+19
View File
@@ -0,0 +1,19 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4v16" />
<rect x="5" y="7" width="3" height="10" rx="1" />
<path d="M10 4v16" />
<rect x="12" y="7" width="3" height="10" rx="1" />
<path d="M17 4v16" />
<rect x="19" y="7" width="3" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 517 B

+19
View File
@@ -0,0 +1,19 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M7 4v16" />
<rect x="2" y="7" width="3" height="10" rx="1" />
<path d="M14 4v16" />
<rect x="9" y="7" width="3" height="10" rx="1" />
<path d="M21 4v16" />
<rect x="16" y="7" width="3" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 516 B

+19
View File
@@ -0,0 +1,19 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3h16" />
<rect x="7" y="5" width="10" height="3" rx="1" />
<path d="M4 10h16" />
<rect x="7" y="12" width="10" height="3" rx="1" />
<path d="M4 17h16" />
<rect x="7" y="19" width="10" height="3" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 517 B

+22
View File
@@ -0,0 +1,22 @@
<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"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<rect x="6" y="2" width="12" height="4" rx="1" />
<path d="M2 4h2" />
<path d="M20 4h2" />
<rect x="6" y="10" width="12" height="4" rx="1" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<rect x="6" y="18" width="12" height="4" rx="1" />
<path d="M2 20h2" />
<path d="M20 20h2" />
</svg>

After

Width:  |  Height:  |  Size: 585 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3h16" />
<path d="M4 21h16" />
<rect x="7" y="9" width="10" height="6" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

+13
View File
@@ -0,0 +1,13 @@
<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"
>
<ellipse cx="12" cy="12" rx="10" ry="7" stroke-dasharray="3.5 3.23" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

+4 -4
View File
@@ -9,8 +9,8 @@
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" />
<path d="M14 3H7a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h7" stroke-dasharray="2 3" />
<path d="m14 10 2.1 2.1" />
<path d="M10.85 17.7A2.45 2.45 0 1 0 8.4 15.25a1.834 1.834 0 0 1-.496 1.254A.7 .7 0 0 0 8.4 17.7z" />
<path d="M13.278 14.922 21.265 6.937a.7 .7 0 0 0-2.101-2.101L11.177 12.822" />
</svg>

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 502 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Slice Select tool glyph (not from an icon set): the Slice knife with a pointer. -->
<path d="M10 14l4-4" />
<path d="M14 10l5.6-5.6a2 2 0 0 1 2.8 2.8L16.5 12.5z" />
<path d="M4 12v9l2.5-2.5 2 3.5 1.7-1-2-3.5H12z" />
</svg>

After

Width:  |  Height:  |  Size: 454 B

+17
View File
@@ -0,0 +1,17 @@
<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"
>
<!-- PhotoCraft's own Create Warped Text glyph (not from an icon set): a T above a bowed arc. -->
<path d="M6 5V3h12v2" />
<path d="M12 3v11" />
<path d="M9.5 14h5" />
<path d="M3 17c5 5 13 5 18 0" />
</svg>

After

Width:  |  Height:  |  Size: 419 B

+3
View File
@@ -5,6 +5,9 @@
- **Headless:** an in-process `Headless` session owns the engine and performs file I/O.
- **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control <port>`.
For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register).
The installed binary survives `cargo clean`; a symlink into `target/release` does not.
Start the headless server with:
```sh
+3
View File
@@ -8,8 +8,11 @@ tiles/<blake3>.zst
blobs/<blake3>.zst
thumb.png
composite/preview.png
save.lock
```
`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it.
The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly.
## Implemented load limits
+2
View File
@@ -19,6 +19,8 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered.
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
+89
View File
@@ -17,6 +17,13 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.brianblakely]
sync_github_name = true
[people.abdul-karim-mia]
real_name = "Abdul karim mia"
sync_github_name = false
[people.jonathanwxh-cell]
display_name = "jonathnwxh"
sync_github_name = false
@@ -116,6 +123,9 @@ sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.zi-ger]
sync_github_name = true
[people.Losminek]
sync_github_name = true
@@ -159,6 +169,85 @@ sync_github_name = false
real_name = "Ufuk Efe"
sync_github_name = true
[people.nicolasfalesy]
sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
[people.theoabw]
real_name = "Theo Wilenius"
[people.TaylorBurke]
sync_github_name = true
[people.1Git2Clone]
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.Byxis]
sync_github_name = true
[people.00xJS]
sync_github_name = true
[people.guillehr2]
real_name = "Guillem Hermida"
sync_github_name = true
[people.fjamesprice]
sync_github_name = true
[people.ha1des]
sync_github_name = true
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.AndreeSalazar]
real_name = "Andreé Salazar"
sync_github_name = true
[people.LorenzoFasolino]
real_name = "Lorenzo Fasolino"
display_name = "Lorenzo Fasolino"
sync_github_name = false
[people.zolyy]
real_name = "Gall Zoltan"
sync_github_name = false
[people.theshubh77]
real_name = "Shubham Bhamare"
sync_github_name = true
[people.ksaxena149]
sync_github_name = true
[people.giang17]
real_name = "Giang Nguyen"
sync_github_name = true
[people.aqibmohd271]
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.Gotix-eg]
real_name = "Ahmed Tammam"
display_name = "Gotix"
sync_github_name = true
[people.adi-1108]
real_name = "Aditya Vishwakarma"
sync_github_name = true
[people.benandez]
real_name = "Martin Ball"
+11 -4
View File
@@ -108,11 +108,11 @@ The same reader is shared, as an independent copy, with VectorCraft's `vectorcra
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
mutations, here and in `photocraft-io`'s `tests/it/affinity.rs`.
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four
Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/it/real_files.rs`
parses them; `photocraft-io`'s `tests/it/affinity_corpus.rs` imports and flattens each one and
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
ceiling per file).
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
@@ -139,11 +139,18 @@ 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
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across
the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot
visits (including nulls and non-object values), and four million points created or copied into
compounds. Operand slots are charged before filtering, without first collecting an operand list.
A compound that crosses a limit or contains a cycle is omitted
with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform.
## Privacy
Affinity documents can hold the folder they were saved in, user names, original image paths and
+9
View File
@@ -20,6 +20,15 @@ impl Stream {
self.class(id) == Some(Tag::of(class))
}
/// The raw slot count of an object's array field (including null slots), for charging
/// traversal work before nulls are filtered out. `None` when the field is not an array.
pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option<usize> {
match self.field(id, tag)? {
Value::Array(a) => Some(a.len()),
_ => None,
}
}
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
match self.field(id, tag)? {
+103 -18
View File
@@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
const MAX_COMPOUND_DEPTH: usize = 64;
const MAX_OUTLINE_VISITS: usize = 500_000;
const MAX_OUTLINE_POINTS: usize = 4_000_000;
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
/// transform. Bound work across the entire document instead of memoizing by object id alone.
pub(crate) struct Budget {
visits_left: usize,
points_left: usize,
}
impl Default for Budget {
fn default() -> Self {
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
}
}
impl Budget {
/// Charge an outline call or a raw compound operand slot before inspecting it.
fn visit(&mut self) -> Result<(), &'static str> {
self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?;
Ok(())
}
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
let Some(left) = self.points_left.checked_sub(points) else {
// Do not rebuild an expensive leaf for every subsequent root after exhaustion.
self.visits_left = 0;
return Err("compound geometry budget exhausted");
};
self.points_left = left;
Ok(())
}
}
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
match outline_inner(r, id, class, world, &mut Vec::new()) {
Ok(path) => path,
Err(reason) => {
r.warn(reason);
None
}
}
}
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
r.geometry_budget.visit()?;
if active.contains(&id) {
return Err("cyclic compound shape");
}
// Independent of the layer-tree limit: Comp operands do not pass through Reader::node.
if active.len() >= MAX_COMPOUND_DEPTH {
return Err("compound depth exceeds 64 levels");
}
if class != Tag::of(b"Comp") {
let path = leaf_outline(r, id, class, world);
if let Some((path, _)) = &path {
r.geometry_budget.path(path)?;
}
return Ok(path);
}
active.push(id);
let result = compound(r, id, world, active);
active.pop();
result
}
fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
let s = r.s;
// One path of all operands' contours, filled even-odd, as before. A safety failure rejects
// the whole outline: a partial add/subtract shape could silently change its meaning.
let mut path = Path::default();
// Charge every slot before filtering nulls or malformed values. Stream::objs would scan
// and allocate the whole list first, repeating uncharged work for each shared occurrence.
let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] };
for child in children {
r.geometry_budget.visit()?;
let Value::Obj(Some(c)) = child else { continue };
let c = *c;
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let Some(class) = s.class(c) else { return Ok(None) };
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? {
r.geometry_budget.path(&p)?;
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Ok(Some((path, true)))
}
fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
@@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
b"Comp" => {
// One path of all the operands' contours in the compound's space, filled even-odd: this
// matches add and subtract operands in every public sample; a true union of overlapping
// added operands would differ.
let mut path = Path::default();
for c in s.objs(id, b"Chld") {
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let class = s.class(c)?;
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Some((path, true))
}
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
@@ -134,7 +210,16 @@ fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
// Index each record's maximum radius once. Scanning the whole radius list per anchor
// was O(anchors * radii), which a file with large anchor and radius arrays could drive
// into quadratic work. Duplicate record indices keep the maximum, matching the prior
// fold(0.0, f64::max) behavior.
let mut radius_by_record: std::collections::HashMap<usize, f64> = std::collections::HashMap::with_capacity(radii.len());
for &(rec, r) in radii {
let slot = radius_by_record.entry(rec).or_insert(0.0);
*slot = slot.max(r);
}
let corner = |a: &Anchor| radius_by_record.get(&a.record).copied().unwrap_or(0.0);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
+26 -1
View File
@@ -283,6 +283,12 @@ const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Total raw `Chld` slots inspected across the whole traversal, including null slots and slots
/// reached through repeated references to a shared group. The node limit only counts produced
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
const MAX_CHILD_SLOTS: usize = 8_000_000;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
@@ -297,7 +303,9 @@ pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>,
nodes: usize,
child_slots: usize,
active: HashSet<ObjId>,
pub(crate) geometry_budget: crate::geometry::Budget,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
/// far hold).
pixels_left: usize,
@@ -305,7 +313,17 @@ pub(crate) struct Reader<'s, 'a, 'b> {
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 }
Self {
s,
archive,
dpi: 72.0,
warnings: BTreeMap::new(),
nodes: 0,
child_slots: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
}
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
@@ -373,6 +391,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
// Charge every raw child slot (null slots included) against a document-wide budget
// before filtering. A shared group with a large null-filled Chld array could otherwise
// be rescanned through each reference without the node limit noticing.
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
+16 -1
View File
@@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option<Vec<f64>> {
}
}
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
/// objects, so one can link to itself, and with both links followed at every level a colour read
/// costs up to 2^(depth+1) lookups.
const MAX_SPOT_DEPTH: usize = 4;
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
color_at(r, id, 0)
}
/// [`color`] of an object `depth` spot colours down.
fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
@@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
if depth >= MAX_SPOT_DEPTH {
r.warn("spot colours nested deeper than 4 levels (left empty)");
return None;
}
r.warn("spot colours (imported as their RGB equivalent)");
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
+36 -7
View File
@@ -73,11 +73,40 @@ pub struct Object {
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
/// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object
/// can be referenced by many sibling layers, and each reference used to rescan all of its
/// fields; indexing once keeps repeated lookups from multiplying into quadratic work.
index: std::collections::HashMap<Tag, usize>,
}
impl Object {
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
let mut index = std::collections::HashMap::with_capacity(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
// Positional fields share tag 0 and are not looked up by tag; keep first-match
// semantics for real tags.
if t.0 != 0 {
index.entry(*t).or_insert(i);
}
}
Object { class, kind, chain, fields, index }
}
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
self.index.get(&tag).map(|&i| &self.fields[i].1)
}
/// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
/// tag index so `get` stays correct.
fn set_fields(&mut self, fields: Vec<(Tag, Value)>) {
self.index.clear();
self.index.reserve(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
if t.0 != 0 {
self.index.entry(*t).or_insert(i);
}
}
self.fields = fields;
}
}
@@ -124,10 +153,10 @@ pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
o.fields = fields;
o.set_fields(fields);
}
Ok(Stream { root, objects: p.objects })
}
@@ -252,7 +281,7 @@ impl Parser<'_> {
}
0x30 => {
let fields = self.fields(false, class)?;
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
@@ -350,7 +379,7 @@ impl Parser<'_> {
}
};
let fields = self.fields(true, class)?;
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?;
Ok(Value::Obj(Some(id)))
}
@@ -369,7 +398,7 @@ impl Parser<'_> {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
@@ -395,7 +424,7 @@ impl Parser<'_> {
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
*o = Object { class, kind: Kind::Shared, chain, fields };
*o = Object::new(class, Kind::Shared, chain, fields);
}
Ok(Value::Obj(Some(id)))
}
+172 -1
View File
@@ -1,4 +1,4 @@
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
@@ -538,3 +538,174 @@ mod pixels {
);
}
}
/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB
/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process
/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded
/// recursion fails the one test that caused it instead of taking the other tests in the binary
/// down with it.
mod spot_colours {
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::container::Limits;
use crate::model::{Document, Kind};
use crate::paint::{Color, Paint};
use crate::stream::Tag;
use crate::synth::{self, F, Method, tag};
/// A one-page document whose page holds `top`, read in a child process first and then here.
fn read(top: Vec<F>) -> Document {
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]);
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
let test = std::thread::current().name().unwrap_or("reader").to_owned();
read_in_child(&bytes, &test);
read_on_small_stack(bytes, &test).unwrap()
}
/// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test.
fn read_on_small_stack(bytes: Vec<u8>, test: &str) -> Result<Document, crate::Error> {
let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap();
reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic))
}
/// Set in the child process to the name of the test it reads for.
const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD";
/// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if
/// that process crashes, panics or takes over 20 s, or never read the document.
fn read_in_child(bytes: &[u8], test: &str) {
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"])
.env(CHILD, test)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let drain = |mut pipe: Box<dyn Read + Send>| {
std::thread::spawn(move || {
let mut text = String::new();
let _ = pipe.read_to_string(&mut text);
text
})
};
let stdout = drain(Box::new(child.stdout.take().unwrap()));
let stderr = drain(Box::new(child.stderr.take().unwrap()));
// Fed from a thread, so a child that never reads cannot block the deadline below; a child
// that dies first breaks the pipe and is reported by its exit status.
let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec());
let feed = std::thread::spawn(move || {
let _ = stdin.write_all(&input);
});
let deadline = Instant::now() + Duration::from_secs(20);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break Some(status);
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(Duration::from_millis(10));
};
feed.join().unwrap();
let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap());
match status {
None => panic!("reading the document did not finish within 20 s:\n{output}"),
Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"),
// A filter that matched no test would also exit successfully, having read nothing.
Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"),
}
}
/// Not a test of its own: `read_in_child` runs it in a child process to read the document on
/// its stdin. Without the variable, as when run with `--ignored`, it does nothing.
#[test]
#[ignore = "run by read_in_child in a child process"]
fn read_child() {
let Some(test) = std::env::var_os(CHILD) else { return };
let mut bytes = Vec::new();
std::io::stdin().read_to_end(&mut bytes).unwrap();
let len = bytes.len();
let _ = read_on_small_stack(bytes, &test.to_string_lossy());
println!("read {len} bytes");
}
/// An ellipse filled with `colour`.
fn filled(id: u32, colour: F) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])),
(tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])),
(tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])),
],
)
}
fn spot(id: u32, fields: Vec<(Tag, F)>) -> F {
F::Def(id, vec![tag(b"Pant")], fields)
}
fn fills(doc: &Document) -> &[Paint] {
match &doc.spreads[0].nodes[0].kind {
Kind::Shape { fills, .. } => fills,
other => panic!("expected a shape, got {other:?}"),
}
}
fn warned(doc: &Document, what: &str) -> bool {
doc.warnings.iter().any(|w| w.contains(what))
}
#[test]
fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() {
let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
#[test]
fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() {
let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
#[test]
fn spot_colours_of_spot_colours_do_not_take_exponential_time() {
// Each level's RGB equivalent and base are the next level, and the last is in an unknown
// model, so both are followed everywhere: 2^41 colour reads before the fix.
fn level(id: u32, left: u32) -> F {
if left == 0 {
return F::Def(id, vec![tag(b"XXXX")], vec![]);
}
spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))])
}
let doc = read(vec![filled(10, level(100, 40))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
}
/// `spots` spot colours, each defined through the next, around an opaque RGB colour.
fn spots(id: u32, spots: u32) -> F {
if spots == 0 {
let rgba: Vec<u8> = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect();
return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]);
}
spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))])
}
#[test]
fn spot_colours_nest_four_levels_and_no_deeper() {
let doc = read(vec![filled(10, spots(100, 4))]);
assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]);
let doc = read(vec![filled(10, spots(100, 5))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
}
+214
View File
@@ -0,0 +1,214 @@
//! #6423: compound operands form a graph, not necessarily a tree.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use photocraft_affinity::model::{Document, Kind};
use photocraft_affinity::synth::{self, F, Method, tag};
use photocraft_affinity::{Limits, read};
use std::time::{Duration, Instant};
fn compound(id: u32, children: Vec<F>) -> F {
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
}
fn rectangle() -> F {
F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))])
}
fn document_bytes(definitions: Vec<F>, roots: Vec<F>) -> Vec<u8> {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
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, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))],
)]),
)],
),
),
]);
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
}
fn document(definitions: Vec<F>, roots: Vec<F>) -> Document {
read(&document_bytes(definitions, roots), Limits::default()).unwrap()
}
// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang)
// inside a child process, so the regression fails without killing the entire test runner.
fn isolated(name: &str, check: impl FnOnce()) {
if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) {
check();
return;
}
// The harness names a test by its module path below the crate root.
let test = module_path!().split_once("::").map_or(name.to_string(), |(_, m)| format!("{m}::{name}"));
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test, "--nocapture"])
.env("PHOTOCRAFT_COMPOUND_CASE", name)
.spawn()
.unwrap();
let start = Instant::now();
loop {
if let Some(status) = child.try_wait().unwrap() {
assert!(status.success(), "compound import failed: {status}");
break;
}
if start.elapsed() > Duration::from_secs(10) {
child.kill().unwrap();
child.wait().unwrap();
panic!("compound import exceeded ten seconds");
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn unsupported(doc: &Document, warning: &str) {
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported));
assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings);
}
#[test]
fn self_referencing_compound_is_reported_without_aborting() {
isolated("self_referencing_compound_is_reported_without_aborting", || {
let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]);
unsupported(&doc, "cyclic compound");
assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes");
});
}
#[test]
fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() {
isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || {
let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]);
unsupported(&doc, "cyclic compound");
});
}
fn nested(levels: u32, repeated: bool) -> Document {
// Flat definitions bypass the stream parser's nesting cap: only references form the depth.
let mut definitions = vec![rectangle()];
let mut previous = 100;
for id in 200..200 + levels {
let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] };
definitions.push(compound(id, children));
previous = id;
}
document(definitions, vec![F::Ref(previous)])
}
#[test]
fn deep_compound_graph_is_bounded_on_a_small_stack() {
isolated("deep_compound_graph_is_bounded_on_a_small_stack", || {
std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap();
});
}
#[test]
fn shared_operands_cannot_expand_exponentially() {
isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget"));
}
#[test]
fn shared_empty_operands_also_have_a_work_limit() {
isolated("shared_empty_operands_also_have_a_work_limit", || {
let mut definitions = vec![compound(100, vec![])];
let mut previous = 100;
for id in 200..240 {
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
previous = id;
}
unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget");
});
}
#[test]
fn geometry_budget_is_shared_across_layer_roots() {
isolated("geometry_budget_is_shared_across_layer_roots", || {
let mut definitions = vec![rectangle()];
let mut previous = 100;
for id in 200..214 {
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
previous = id;
}
let mut roots = vec![F::Ref(previous); 12];
roots.push(F::Ref(100));
let doc = document(definitions, roots);
let nodes = &doc.spreads[0].nodes;
assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports");
assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget");
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
});
}
#[test]
fn ordinary_shared_operands_keep_both_transformed_copies() {
let mut translated = compound(201, vec![F::Ref(100)]);
let F::Def(_, _, fields) = &mut translated else { unreachable!() };
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0])));
let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]);
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
assert_eq!(path.subpaths.len(), 2);
for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) {
assert_eq!(b.x, a.x + 30.0);
assert_eq!(b.y, a.y + 40.0);
}
}
#[test]
fn null_operand_slots_cannot_bypass_geometry_budget() {
isolated("null_operand_slots_cannot_bypass_geometry_budget", || {
let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]);
let start = Instant::now();
let doc = read(&bytes, Limits::default()).unwrap();
eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64());
unsupported(&doc, "compound geometry budget");
assert_eq!(doc.spreads[0].nodes.len(), 20_000);
assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported)));
});
}
#[test]
fn null_operand_slots_share_budget_across_layer_roots() {
isolated("null_operand_slots_share_budget_across_layer_roots", || {
let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]);
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid");
assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported));
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
});
}
#[test]
fn null_operand_slots_cannot_leave_a_partial_shape() {
isolated("null_operand_slots_cannot_leave_a_partial_shape", || {
let mut slots = vec![F::Ref(100)];
slots.extend(vec![F::Null; 1_000_000]);
let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]);
unsupported(&doc, "compound geometry budget");
});
}
#[test]
fn non_object_operand_slots_also_consume_budget() {
isolated("non_object_operand_slots_also_consume_budget", || {
let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]);
unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget");
});
}
#[test]
fn ordinary_null_operand_slots_preserve_shared_contours() {
let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]);
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
assert_eq!(path.subpaths.len(), 2);
assert_eq!(path.subpaths[0], path.subpaths[1]);
}
+4
View File
@@ -0,0 +1,4 @@
//! The crate's integration tests, linked into one test binary.
mod compound_limits;
mod real_files;
File renamed without changes.
+8 -1
View File
@@ -33,6 +33,8 @@ fn main() {
depth_map: None,
},
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 12.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 100.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::ShapeBlur { radius: 10.0, shape: BlurShape::Star },
FilterParams::TiltShift { blur: 15.0, center_x: 0.5, center_y: 0.5, angle: 0.0, focus: 0.1, transition: 0.15 },
FilterParams::IrisBlur { pins: vec![IrisPin::default()] },
@@ -60,7 +62,12 @@ fn main() {
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
println!("{:<18} {w}x{h} RGBA8: {:.2?}", p.label(), t.elapsed());
// Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle.
let name = match p {
FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()),
_ => p.label().to_string(),
};
println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed());
drop(out);
}
}
+17 -4
View File
@@ -127,10 +127,23 @@ fn main() {
photocraft_algo::puppet::ITERATIONS,
t.elapsed().as_secs_f64() * 1e3
);
let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
let t = Instant::now();
let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v));
println!("puppet interactive re-solve (4 warm rounds): {:.2} ms", t.elapsed().as_secs_f64() * 1e3);
// Compare the old path-dependent preview with the command-equivalent drag solve, using
// changing destinations and the same cached factorization (no remeshing per event).
for (label, rounds, warm) in [("6 warm rounds", 6, true), ("canonical cold solve", photocraft_algo::puppet::ITERATIONS, false)] {
let mut previous = v.clone();
let mut times = Vec::new();
for step in 1..=60 {
let mut dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
dst[1][0] -= f64::from(step) * 10.0;
dst[1][1] += (f64::from(step) * 0.1).sin() * 400.0;
let t = Instant::now();
let (next, _) = solver.solve(&dst, &[None; 3], pw.mode, rounds, warm.then_some(previous.as_slice()));
times.push(t.elapsed().as_secs_f64() * 1e3);
previous = std::hint::black_box(next);
}
times.sort_by(f64::total_cmp);
println!("puppet drag ({label}, 60 moves): median {:.2} ms, p95 {:.2} ms, max {:.2} ms", times[30], times[56], times[59]);
}
let t = Instant::now();
let _ = render(&s, b, &solver.mesh, &v, &order, Interp::Bicubic);
println!("puppet render 24 MP: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
+42
View File
@@ -0,0 +1,42 @@
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
//! (3:2), each overlapping the next by half its width.
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
use std::time::Instant;
use photocraft_algo::panorama::{self, RoiImage};
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
fn peak_mib() -> Option<f64> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024.0)
}
fn main() {
let mut args = std::env::args().skip(1);
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
let sw = sh * 3 / 2;
let step = sw / 2;
let (cw, ch) = (step * (n - 1) + sw, sh);
let rois: Vec<RoiImage> = (0..n)
.map(|k| {
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
})
.collect();
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
let wts: Vec<Vec<f32>> = (0..n)
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
.collect();
let levels = panorama::blend_levels(cw, ch).min(7);
let before = peak_mib();
let t0 = Instant::now();
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
let ms = t0.elapsed().as_secs_f64() * 1000.0;
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
std::hint::black_box((px, cov));
}
+140 -25
View File
@@ -437,7 +437,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
F::Chrome => gs(1.0 + g("smoothness") * 0.4) + 2,
F::ConteCrayon => 0,
F::GraphicPen => (g("strokeLength") / 2.0) as i32 + 2,
F::HalftonePattern => gs(0.5 + g("size") * 0.3),
F::HalftonePattern => gs(halftone_cell(e) * 0.15),
F::NotePaper => gs(1.0) + gs(1.2) + 2,
F::Photocopy => gs(1.0 + g("detail") * 0.5),
F::Plaster => gs(0.5 + g("smoothness") * 0.8) + gs(1.5) + 2,
@@ -454,6 +454,55 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
fn cutout_sigma(e: &GalleryEffect) -> f32 {
0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity"))
}
/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12).
fn halftone_cell(e: &GalleryEffect) -> f32 {
1.5 + 2.0 * e.get("size").clamp(1.0, 12.0)
}
/// The round-dot spot function `(cos 2πu + cos 2πv) / 2` sampled over a cell and sorted: a
/// value's rank among them is the share of the cell below it, which makes the thresholds even.
fn halftone_dot_ranks() -> &'static [f32] {
static RANKS: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
RANKS.get_or_init(|| {
const N: usize = 128;
let mut v: Vec<f32> = (0..N * N)
.map(|i| {
let (u, w) = (((i % N) as f32 + 0.5) / N as f32, ((i / N) as f32 + 0.5) / N as f32);
((2.0 * PI * u).cos() + (2.0 * PI * w).cos()) / 2.0
})
.collect();
v.sort_by(f32::total_cmp);
v
})
}
/// For each tone (0 = paper … 1 = solid ink, 256 steps), the level `τ` at which the soft
/// threshold `clamp(0.5 + (τ − s) · gain)` over evenly spread thresholds `s` inks exactly that
/// share of a cell on average.
fn halftone_offsets(gain: f32) -> Vec<f32> {
let half = 0.5 / gain.max(1e-3);
// Average ink over s in [0, 1] for level τ: 1 below τ − half, a ramp to 0 at τ + half.
let cover = |tau: f32| {
let (a, z) = (tau - half, tau + half);
let solid = a.clamp(0.0, 1.0);
let (r0, r1) = (a.clamp(0.0, 1.0), z.clamp(0.0, 1.0));
let ramp = if r1 > r0 { (0.5 + tau * gain) * (r1 - r0) - 0.5 * gain * (r1 * r1 - r0 * r0) } else { 0.0 };
solid + ramp
};
(0..256)
.map(|i| {
let target = i as f32 / 255.0;
let (mut lo, mut hi) = (-half - 1.0, 1.0 + half + 1.0);
for _ in 0..40 {
let mid = (lo + hi) / 2.0;
if cover(mid) < target { lo = mid } else { hi = mid }
}
match i {
0 => -half - 1.0,
255 => 1.0 + half + 1.0,
_ => (lo + hi) / 2.0,
}
})
.collect()
}
fn daubs_radius(e: &GalleryEffect) -> usize {
let wide = matches!(e.choice("brushType"), "wideSharp" | "wideBlurry");
let r = e.get("brushSize") / 2.0 * if wide { 1.5 } else { 1.0 };
@@ -506,6 +555,7 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
res
}
/// Applies one gallery effect to the working window in place.
fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
@@ -933,36 +983,46 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
}
}
F::HalftonePattern => {
let size = g("size");
let con = g("contrast") / 50.0;
let cell = 3.0 + size * 2.0;
// A halftone screen that keeps a continuous range of tones: each pixel compares its
// ink (the darkness of the image) with the screen's threshold there, and Contrast is
// how steeply: at 0 the image keeps its tones with the screen faintly through it, at
// 50 it is crisp dots, lines or rings. The thresholds are spread evenly over each cell,
// and each tone is offset so a cell's average ink is exactly that tone: black stays
// solid and white stays paper at any Contrast.
let cell = halftone_cell(e);
let gain = 0.5 + g("contrast") * 0.6;
let offsets = halftone_offsets(gain);
let mut l = win.lum();
blur1(&mut l, w, h, 0.5 + size * 0.3);
blur1(&mut l, w, h, cell * 0.15);
let b = ctx.bounds;
let (cx, cy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
let ty = e.choice("patternType");
let dots = halftone_dot_ranks();
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let d = clamp01((1.0 - l[i] - 0.5) * (1.0 + con * 3.0) + 0.5);
let aa = 1.0 / cell;
let ink = match ty {
"circle" => {
let r = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt() / cell;
let q = (r.fract() - 0.5).abs() * 2.0;
smoothstep(d + aa, d - aa, q)
}
"line" => {
let q = ((fy / cell).fract() - 0.5).abs() * 2.0;
smoothstep(d + aa, d - aa, q)
}
let band = |t: f32| ((t / cell).rem_euclid(1.0) - 0.5).abs() * 2.0;
let spot = match ty {
// Rings round the middle of the image, inked from the middle of each ring.
"circle" => band(((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt()),
// Horizontal lines, inked from the middle of each.
"line" => band(fy),
// Round dots on a screen at 45° (as a black halftone screen is), that grow
// from each cell's centre, meet at 50% as a checkerboard of squares square to
// the image, and fill the cell at 100%.
_ => {
let (u, v) = ((fx / cell).fract() - 0.5, (fy / cell).fract() - 0.5);
let q = (u * u + v * v).sqrt() * 2.0;
let rr = d.sqrt() * 1.15;
smoothstep(rr + aa, rr - aa, q)
let (rx, ry) = ((fx + fy) * std::f32::consts::FRAC_1_SQRT_2, (fy - fx) * std::f32::consts::FRAC_1_SQRT_2);
let (u, v) = ((rx / cell).rem_euclid(1.0), (ry / cell).rem_euclid(1.0));
let f = ((2.0 * PI * u).cos() + (2.0 * PI * v).cos()) / 2.0;
let k = dots.partition_point(|&r| r < f);
k as f32 / dots.len().max(1) as f32
}
};
let tone = clamp01(1.0 - l[i]) * (offsets.len() - 1) as f32;
let (k, f) = (tone.floor() as usize, tone.fract());
let at = |j: usize| offsets.get(j.min(offsets.len() - 1)).copied().unwrap_or(0.5);
let level = at(k) + (at(k + 1) - at(k)) * f;
let ink = clamp01(0.5 + (level - spot) * gain);
win.c[i] = mix(bg, fg, ink);
}
}
@@ -1254,6 +1314,43 @@ mod tests {
}
}
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
/// solid, and Contrast makes the screen crisper.
#[test]
fn halftone_keeps_tones_and_contrast_sets_how_hard() {
// It used to be all ink or paper at any Contrast, with white crosses in the shadows
// (dots could never fill their cell) and mid-greys that came out the wrong darkness.
use crate::GalleryFilter as G;
let b = Rect::new(0, 0, 60, 60);
let run = |grey: f32, pattern: &str, contrast: f32| {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
s.fill_rect(b, &[grey, grey, grey, 1.0]);
let mut e = GalleryEffect::new(G::HalftonePattern);
assert!(e.set_choice("patternType", pattern));
e.set("size", 4.0);
e.set("contrast", contrast);
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
// Ink (1 = foreground black) of every pixel away from the canvas edge.
out.chunks(4).enumerate().filter(|(i, _)| (6..54).contains(&(i % 60)) && (6..54).contains(&(i / 60))).map(|(_, p)| 1.0 - p[0]).collect::<Vec<f32>>()
};
for pattern in ["dot", "circle", "line"] {
for contrast in [0.0, 5.0, 50.0] {
for grey in [0.2, 0.5, 0.8] {
let ink = run(grey, pattern, contrast);
let mean = ink.iter().sum::<f32>() / ink.len() as f32;
assert!((mean - (1.0 - grey)).abs() < 0.06, "{pattern} contrast {contrast}: grey {grey} inks {mean}");
}
let solid = run(0.0, pattern, contrast);
assert!(solid.iter().all(|&v| v > 0.99), "{pattern} contrast {contrast}: black isn't solid");
let paper = run(1.0, pattern, contrast);
assert!(paper.iter().all(|&v| v < 0.01), "{pattern} contrast {contrast}: white isn't paper");
}
let soft = run(0.5, pattern, 0.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
let hard = run(0.5, pattern, 50.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
assert!(hard * 4 < soft, "{pattern}: contrast 50 should be much crisper ({hard} vs {soft} grey pixels)");
}
}
#[test]
fn params_parse_and_clamp() {
for f in crate::GalleryFilter::ALL {
@@ -1305,7 +1402,7 @@ mod tests {
}
/// Visual check: `GALLERY_RAW=src.raw GALLERY_W=480 GALLERY_H=322 cargo test -p photocraft-algo --release gallery_sheet -- --ignored`
/// writes `<key>.raw` (RGB8) beside the input for every filter.
/// writes `<key>.raw` (RGB8) beside the input for every filter (or one, see `GALLERY_ONLY`).
#[test]
#[ignore]
fn gallery_sheet() {
@@ -1318,11 +1415,29 @@ mod tests {
let b = Rect::new(0, 0, w, h);
s.write_region(b, &data);
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
for f in crate::GalleryFilter::ALL {
let fp = FilterParams::FilterGallery { effects: vec![GalleryEffect::new(*f)] };
// Optional: GALLERY_ONLY=<key> renders one filter; GALLERY_SET="key=value,key=name" sets its
// sliders (numbers) or choices (names); GALLERY_TAG is appended to the file name.
let only = std::env::var("GALLERY_ONLY").ok();
let set = std::env::var("GALLERY_SET").unwrap_or_default();
let tag = std::env::var("GALLERY_TAG").unwrap_or_default();
if let Some(k) = &only {
assert!(crate::GalleryFilter::ALL.iter().any(|f| f.key() == k), "GALLERY_ONLY={k} is not a gallery filter");
}
for f in crate::GalleryFilter::ALL.iter().filter(|f| only.as_deref().is_none_or(|k| k == f.key())) {
let mut e = GalleryEffect::new(*f);
for kv in set.split(',').filter(|kv| !kv.is_empty()) {
let (k, v) = kv.split_once('=').unwrap();
// `set` ignores unknown keys; a misspelt one would render the default unnoticed.
assert!(f.params().iter().any(|p| p.key == k), "{k} is not a parameter of {}", f.key());
match v.parse::<f32>() {
Ok(n) => e.set(k, n),
Err(_) => assert!(e.set_choice(k, v), "{k} has no choice {v}"),
}
}
let fp = FilterParams::FilterGallery { effects: vec![e] };
let out = crate::apply_in(&s, &fp, b, b, None, b).read_region(b);
let raw: Vec<u8> = out.chunks(4).flat_map(|p| [p[0], p[1], p[2]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
std::fs::write(dir.join(format!("{}.raw", f.key())), raw).unwrap();
std::fs::write(dir.join(format!("{}{tag}.raw", f.key())), raw).unwrap();
}
}
}
+69 -4
View File
@@ -212,12 +212,17 @@ fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
}
/// Box passes of the given radii in turn along the line in `s.line` (`w`-float elements, as many
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`.
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch) {
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`. Stops early (leaving
/// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of
/// milliseconds on a tall image, too long to finish once cancelled (#6205).
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) {
let Scratch { line, next, acc } = s;
let Some(len) = dst.len().checked_div(w) else { return };
let mut tail: usize = radii.iter().sum();
for (k, &r) in radii.iter().enumerate() {
if ctl.cancelled() {
return;
}
tail -= r;
if k + 1 == radii.len() {
box_pass(line, dst, w, r, acc);
@@ -281,7 +286,7 @@ pub(crate) fn box_blur(
(0..left).for_each(|_| s.line.extend_from_slice(first));
s.line.extend_from_slice(&row);
(0..right).for_each(|_| s.line.extend_from_slice(last));
line_passes(dst, n, &radii, &mut s);
line_passes(dst, n, &radii, &mut s, ctl);
}
});
// Vertical passes, strip by strip, the same way down the columns.
@@ -300,7 +305,10 @@ pub(crate) fn box_blur(
(0..rows).for_each(|y| s.line.extend_from_slice(row(y)));
(0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1)));
let mut res = vec![0.0f32; oh * w];
line_passes(&mut res, w, &radii, &mut s);
line_passes(&mut res, w, &radii, &mut s, ctl);
if ctl.cancelled() {
return (t, Vec::new());
}
unpremultiply(&mut res, n, alpha);
(t, res)
});
@@ -1180,4 +1188,61 @@ mod tests {
}
}
}
/// A column strip's passes stop between passes once cancelled, not only before the strip
/// (#6205); uncancelled, every pass runs.
#[test]
fn line_passes_stop_between_passes_when_cancelled() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (radii, w, len) = ([2usize, 3, 2], 4usize, 50usize);
let reach: usize = radii.iter().sum();
let line: Vec<f32> = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect();
let run = |ctl: &Interrupt| {
let mut s = Scratch { line: line.clone(), ..Scratch::default() };
let mut dst = vec![0.0f32; len * w];
line_passes(&mut dst, w, &radii, &mut s, ctl);
dst
};
assert!(run(&Interrupt::NONE).iter().all(|v| *v > 0.0));
let calls = AtomicUsize::new(0);
let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 1;
// Cancelled after the first pass: the last pass (the one writing `dst`) never runs.
assert!(run(&Interrupt::cancel_only(&cancel)).iter().all(|v| *v == 0.0));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
/// A box blur cancelled at any of its checks returns `None` (never a half-blurred result);
/// one that is asked but never cancelled is bit-identical to the uninterruptible run.
#[test]
fn box_blur_honours_a_cancel_at_every_check() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (n, src) = (4usize, Rect::new(0, 0, 150, 90));
let px: Vec<f32> = (0..src.width() as usize * src.height() as usize * n).map(|i| (i as f32 * 0.13).sin() * 0.5 + 0.5).collect();
let read = |r: Rect, buf: &mut Vec<f32>| {
buf.clear();
let (x0, x1) = ((r.x0 * n as i32) as usize, (r.x1 * n as i32) as usize);
let row = r.y0 as usize * src.width() as usize * n;
buf.extend_from_slice(&px[row + x0..row + x1]);
};
let Some(boxes) = box_widths(&FilterParams::GaussianBlur { radius: 12.0 }) else { panic!("box passes") };
let out = Rect::new(-5, 3, 155, 80);
let want = box_blur(src, &read, out, n, true, &boxes, &Interrupt::NONE).unwrap();
let checks = AtomicUsize::new(0);
let count = || {
checks.fetch_add(1, Ordering::SeqCst);
false
};
let got = box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&count)).unwrap();
assert_eq!(got.len(), want.len());
for ((ta, a), (tb, b)) in got.iter().zip(&want) {
assert_eq!(ta, tb);
assert!(a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()), "strip {ta:?} differs");
}
let total = checks.load(Ordering::SeqCst);
for k in (0..total).step_by((total / 40).max(1)) {
let seen = AtomicUsize::new(0);
let cancel = || seen.fetch_add(1, Ordering::SeqCst) >= k;
assert!(box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&cancel)).is_none(), "cancelled at check {k}");
}
}
}
+125 -22
View File
@@ -189,6 +189,11 @@ mod shape_tests;
/// Smart Blur: averages only neighbours within `threshold` levels of the
/// centre pixel (so edges stay crisp); Edge Only / Overlay Edge draw the
/// edges (where neighbours differ by more than the threshold) in white.
///
/// Neighbours outside `src` read as zero, as [`Image::get`] does. Pixels whose whole
/// kernel lies inside `src` (all of them, with the usual halo) read neighbours at
/// precomputed offsets into the window instead of by coordinates, and for up to five
/// channels go through [`smart_rows`] (#7921).
pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -201,31 +206,48 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
BlurQuality::High => 12,
};
let stride = ((ri + target - 1) / target).max(1);
let offs: Vec<(i32, i32)> = (-ri..=ri)
let win = src.rect;
let ww = win.width() as isize;
// Each offset with its distance in samples within the window.
let offs: Vec<(i32, i32, isize)> = (-ri..=ri)
.step_by(stride as usize)
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
.map(|(dx, dy)| (dx, dy, (dy as isize * ww + dx as isize) * n as isize))
.collect();
let at = |x: i32, y: i32| win.contains(x, y).then(|| ((y - win.y0) as usize * ww as usize + (x - win.x0) as usize) * n);
let zeros = [0.0f32; MAXC];
let data = &src.data[..];
// The pixel at sample `i`, or zeros outside the window.
let sample = |i: Option<usize>| i.and_then(|i| data.get(i..i + n)).unwrap_or(&zeros[..n]);
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
let mut res = src.crop(out);
let mut q = [0.0f32; MAXC];
let inner = Rect::new(win.x0.saturating_add(ri), win.y0.saturating_add(ri), win.x1.saturating_sub(ri), win.y1.saturating_sub(ri)).intersect(&out);
let lin: Vec<isize> = offs.iter().map(|o| o.2).collect();
let rows = match (mode, n, ctx.alpha) {
(SmartBlurMode::Normal, 1, false) => smart_rows::<1, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 1, true) => smart_rows::<1, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 2, false) => smart_rows::<2, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 2, true) => smart_rows::<2, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 3, false) => smart_rows::<3, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 3, true) => smart_rows::<3, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 4, false) => smart_rows::<4, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 4, true) => smart_rows::<4, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 5, false) => smart_rows::<5, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 5, true) => smart_rows::<5, true>(src, out, inner, &lin, t, &mut res),
_ => Rect::EMPTY,
};
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
if rows.contains(x, y) {
continue;
}
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
let differs = |q: &[f32]| q[..cc].iter().zip(&me).any(|(q, m)| (q - m).abs() > t);
if mode != SmartBlurMode::Normal {
let mut edge = false;
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
edge = true;
break;
}
}
let edge = [(1, 0), (0, 1), (-1, 0), (0, -1)].into_iter().any(|(dx, dy)| differs(sample(at(x + dx, y + dy))));
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
if edge {
px[..cc].copy_from_slice(&white[..cc]);
@@ -237,18 +259,28 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
}
continue;
}
let interior = x - ri >= win.x0 && x + ri < win.x1 && y - ri >= win.y0 && y + ri < win.y1;
let centre = if interior { at(x, y) } else { None };
acc[..n].fill(0.0);
let mut wsum = 0.0f32;
for &(dx, dy) in &offs {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
for &(dx, dy, d) in &offs {
let q = sample(match centre {
Some(c) => c.checked_add_signed(d),
None => at(x + dx, y + dy),
});
if differs(q) {
continue;
}
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
for c in 0..n {
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
if ctx.alpha {
let a = q[n - 1];
for (s, v) in acc.iter_mut().zip(&q[..cc]) {
*s += v * a;
}
acc[n - 1] += a;
} else {
for (s, v) in acc.iter_mut().zip(q) {
*s += v;
}
}
wsum += 1.0;
}
@@ -262,6 +294,77 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
res
}
/// Smart Blur's normal mode over `inner` (pixels whose whole kernel lies in `src`) for `N`
/// channels, the last alpha if `A`; `lin` are the neighbours as sample offsets in the window.
/// Each offset sweeps a whole row, so neighbours load contiguously and a rejected one adds
/// `-0.0` (an exact identity) instead of branching: every pixel sums its neighbours in the same
/// order as [`smart_blur`]'s per-pixel loop, giving the same bits. Returns the area written: the
/// rows finished, empty if the layout doesn't match.
fn smart_rows<const N: usize, const A: bool>(src: &Image, out: Rect, inner: Rect, lin: &[isize], t: f32, res: &mut [f32]) -> Rect {
let win = src.rect;
if src.ch != N || inner.is_empty() || !win.contains_rect(&inner) || !out.contains_rect(&inner) {
return Rect::EMPTY;
}
let cc = if A { N - 1 } else { N };
let (ww, ow, len) = (win.width() as usize, out.width() as usize, inner.width() as usize);
let pixels = len * N;
let mut acc = vec![[0.0f32; N]; len];
let mut wsum = vec![0.0f32; len];
// Rows before `y` are done; a row is written only once all its neighbours were read.
let done = |y: i32| Rect::new(inner.x0, inner.y0, inner.x1, y);
for y in inner.y0..inner.y1 {
let centre = ((y - win.y0) as usize * ww + (inner.x0 - win.x0) as usize) * N;
let Some(me) = src.data.get(centre..centre + pixels) else {
return done(y);
};
let me = me.as_chunks::<N>().0;
acc.fill([0.0; N]);
wsum.fill(0.0);
for &d in lin {
let Some(q) = centre.checked_add_signed(d).and_then(|i| src.data.get(i..i + pixels)) else {
return done(y);
};
for (((s, w), q), m) in acc.iter_mut().zip(wsum.iter_mut()).zip(q.as_chunks::<N>().0).zip(me) {
// As `differs`: a NaN difference is not over the threshold.
let mut far = false;
for c in 0..cc {
far |= (q[c] - m[c]).abs() > t;
}
let keep = !far;
if A {
let a = q[N - 1];
for c in 0..N - 1 {
s[c] += if keep { q[c] * a } else { -0.0 };
}
s[N - 1] += if keep { a } else { -0.0 };
} else {
for c in 0..N {
s[c] += if keep { q[c] } else { -0.0 };
}
}
*w += if keep { 1.0 } else { 0.0 };
}
}
let o = ((y - out.y0) as usize * ow + (inner.x0 - out.x0) as usize) * N;
let Some(dst) = res.get_mut(o..o + pixels) else {
return done(y);
};
for ((px, s), &w) in dst.as_chunks_mut::<N>().0.iter_mut().zip(&acc).zip(&wsum) {
if w > 0.0 {
for c in 0..N {
px[c] = s[c] / w;
}
unpremul_px(px, A);
}
}
}
inner
}
#[cfg(test)]
#[path = "smart_blur_tests.rs"]
mod smart_tests;
// ---------- lens blur ----------
pub(crate) struct LensSpec<'a> {
@@ -335,7 +438,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V
}
src.get(x, y, n - 1)
}
DepthSource::LayerMask => spec.depth_map?.get(x, y, 0),
DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0),
};
Some(if spec.invert { 1.0 - d } else { d })
};
+2 -8
View File
@@ -67,22 +67,16 @@ impl Encoding {
pub(crate) fn of(gamma: f32) -> Self {
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
}
/// A value in this encoding as linear light.
pub(crate) fn decode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
}
/// Linear light back into this encoding, the inverse of [`Encoding::decode`].
pub(crate) fn encode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
}
}
fn srgb_encode(v: f32) -> f32 {
linear_to_srgb(v)
}
fn srgb_decode(v: f32) -> f32 {
srgb_to_linear(v)
}
fn luma_linear(c: [f32; 3]) -> f32 {
luma(c)
}
+63 -6
View File
@@ -173,6 +173,39 @@ fn up(p: &Plane, w: usize, h: usize) -> Plane {
Plane { w, h, v: out }
}
/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the
/// zero-stuffed row is all zeros).
fn up_rows(p: &Plane, h: usize, y: usize) -> [Option<usize>; 5] {
let lh = p.h.min(h.div_ceil(2));
std::array::from_fn(|k| {
let m = mirror(y as isize + k as isize - 2, h);
(m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2)
})
}
/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`.
/// It adds the same terms in the same order as `up`, so it gives the same bits.
fn up_at(p: &Plane, w: usize, rows: &[Option<usize>; 5], x: usize) -> f32 {
let lw = p.w.min(w.div_ceil(2));
let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() };
if x >= 2 && x + 2 < w {
// Away from the edges no tap mirrors.
(0..5)
.filter_map(|k| {
let i = x + k - 2;
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2))
})
.sum()
} else {
(0..5)
.filter_map(|k| {
let m = mirror(x as isize + k as isize - 2, w);
(m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2))
})
.sum()
}
}
/// Levels down to about 2 pixels on the short side.
fn depth(w: usize, h: usize) -> usize {
let mut s = w.min(h);
@@ -217,14 +250,14 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]);
rows(&mut lap[l], lw, |y, row| {
let taps = up_rows(coarse, lh, y);
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
*o += wgt * (fine[i] - coarse.v[i]);
*o += wgt * (fine[i] - up_at(coarse, lw, &taps, x));
}
}
});
@@ -244,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
// Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a
// fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element.
let i = ((s.len() - 1) as f32 * q).round() as usize;
*s.select_nth_unstable_by(i, f32::total_cmp).1
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
@@ -444,11 +479,33 @@ mod tests {
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
}
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
let full = up(&p, uw, uh);
assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
// The filter samples `up` one pixel at a time; every sample has the same bits.
for y in 0..uh {
let taps = up_rows(&p, uh, y);
for x in 0..uw {
assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}");
}
}
}
}
}
#[test]
fn quantile_is_the_sorted_element() {
let p = noise_plane(301, 211, 9);
let mut v = p.v.clone();
// Ties and signed zeros, which `total_cmp` orders by their bits.
v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]);
let mut sorted = v.clone();
sorted.sort_by(f32::total_cmp);
for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] {
let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize];
assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}");
}
}
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
+2 -2
View File
@@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco
if per_channel {
for c in 0..3 {
let over = (lin[c] - 1.0).max(0.0);
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over);
}
} else {
let y = super::luma_linear(lin).max(0.0);
if y <= 1e-7 {
continue;
}
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0);
let k = y2 / y;
for c in 0..3 {
q[c] = enc.encode(lin[c] * k);
+164
View File
@@ -566,6 +566,133 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3
.collect()
}
/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of
/// diameter `size` along `points` (document px), adding to the sampling area or taking from it.
#[derive(Clone, Debug, PartialEq)]
pub struct SamplingStroke {
pub add: bool,
pub size: f64,
pub points: Vec<[f64; 2]>,
}
impl SamplingStroke {
/// Bounds of the pixels the stroke covers.
pub fn bounds(&self) -> Rect {
let r = (self.size / 2.0).max(0.5);
self.points.iter().fold(Rect::EMPTY, |acc, p| {
let b = Rect::new(
(p[0] - r).floor() as i32,
(p[1] - r).floor() as i32,
((p[0] + r).ceil() as i32).saturating_add(1),
((p[1] + r).ceil() as i32).saturating_add(1),
);
if acc.is_empty() { b } else { acc.union(&b) }
})
}
}
/// Squared distance from `p` to the segment `a`–`b`.
fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]);
qx * qx + qy * qy
}
/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within
/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). A mask that
/// does not cover `area` exactly is left as it is.
pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) {
let w = area.width() as usize;
if mask.len() != w * area.height() as usize {
return;
}
for s in strokes {
let r = (s.size / 2.0).max(0.5);
let r2 = r.powi(2);
let segs: Vec<([f64; 2], [f64; 2])> = match s.points.as_slice() {
[] => continue,
[p] => vec![(*p, *p)],
pts => pts.windows(2).map(|p| (p[0], p[1])).collect(),
};
// A stroke covers the union of its segments' pixels: each segment only looks at its own
// bounds.
for (a, b) in segs {
let bounds = Rect::new(
(a[0].min(b[0]) - r).floor() as i32,
(a[1].min(b[1]) - r).floor() as i32,
((a[0].max(b[0]) + r).ceil() as i32).saturating_add(1),
((a[1].max(b[1]) + r).ceil() as i32).saturating_add(1),
)
.intersect(&area);
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
let c = [f64::from(x) + 0.5, f64::from(y) + 0.5];
if seg_dist2(c, a, b) <= r2
&& let Some(m) = mask.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize)
{
*m = s.add;
}
}
}
}
}
}
/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule):
/// what a lasso outlines.
pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec<bool> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut out = vec![false; w * h];
if pts.len() < 3 {
return out;
}
let mut xs: Vec<f64> = Vec::new();
for row in 0..h {
let cy = f64::from(area.y0) + row as f64 + 0.5;
xs.clear();
for i in 0..pts.len() {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
if (a[1] <= cy) != (b[1] <= cy) {
xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0]));
}
}
xs.sort_by(f64::total_cmp);
for pair in xs.as_chunks::<2>().0 {
// Pixel centres x + 0.5 within [pair0, pair1).
let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true);
}
}
out
}
/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand
/// and Contract.
pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec<bool> {
if by == 0 || w == 0 || h == 0 {
return mask.to_vec();
}
let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX));
if by > 0 {
let d = crate::selection::edt(mask, w, h);
d.iter().map(|v| *v <= r).collect()
} else {
let outside: Vec<bool> = mask.iter().map(|v| !v).collect();
let d = crate::selection::edt(&outside, w, h);
d.iter().map(|v| *v > r).collect()
}
}
/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling
/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100).
pub fn default_brush_size(hole: Rect) -> f64 {
let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50)));
(0.4 * (w * h).sqrt()).round()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -577,6 +704,43 @@ mod tests {
(img, hole)
}
#[test]
fn strokes_paint_their_path_and_leave_a_mismatched_mask_alone() {
let area = Rect::new(10, 20, 50, 40);
let (w, h) = (40, 20);
let strokes = [
SamplingStroke { add: true, size: 6.0, points: vec![[12.0, 25.0], [40.0, 25.0], [45.0, 35.0]] },
SamplingStroke { add: false, size: 3.0, points: vec![[30.0, 25.0]] },
SamplingStroke { add: true, size: 2.0, points: Vec::new() },
];
let mut mask = vec![false; w * h];
paint_strokes(&mut mask, area, &strokes);
// The same pixels as testing every pixel against every segment of each stroke in turn.
let mut want = vec![false; w * h];
for s in &strokes {
let r2 = (s.size / 2.0).max(0.5).powi(2);
let segs: Vec<_> = if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() };
for (i, m) in want.iter_mut().enumerate() {
let c = [f64::from(area.x0) + (i % w) as f64 + 0.5, f64::from(area.y0) + (i / w) as f64 + 0.5];
if segs.iter().any(|(a, b)| seg_dist2(c, *a, *b) <= r2) {
*m = s.add;
}
}
}
assert_eq!(mask, want);
assert!(mask[5 * w + 10] && !mask[5 * w + 20], "added along the path, cleared under the subtracting dab");
// A mask shorter or longer than the area is not touched (and nothing panics).
for len in [0, 7, w * h - 1, w * h + 3] {
let mut short = vec![false; len];
paint_strokes(&mut short, area, &strokes);
assert!(short.iter().all(|v| !v), "len {len}");
}
// Hostile coordinates stay inside the area.
let mut m = vec![false; w * h];
let wild = [SamplingStroke { add: true, size: 5000.0, points: vec![[f64::MAX, -1e300], [f64::NAN, 1e7]] }];
paint_strokes(&mut m, area, &wild);
}
#[test]
fn cancelled_fill_stops_and_progress_reaches_one() {
use std::sync::atomic::{AtomicU32, Ordering};
+36 -19
View File
@@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) {
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0)
}
/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse
/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all
/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to
/// unit-circle coordinates `(u, v)` and back.
fn ellipse(b: Rect) -> (f32, f32, f32, f32) {
let (w, h) = (b.width() as f32, b.height() as f32);
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0)
}
pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = angle.to_radians();
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || rr <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let t = a * (1.0 - r / rr);
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 {
return (x, y);
}
let t = a * (1.0 - r);
let (s, c) = t.sin_cos();
(cx + dx * c - dy * s, cy + dx * s + dy * c)
(cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy)
})
}
pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * (r / rr).powf(1.0 - a * 0.5);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = r.powf(1.0 - a * 0.5) / r;
(cx + u * k * hx, cy + v * k * hy)
})
}
@@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 {
}
pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec<f32> {
let b = ctx.bounds;
let (cx, cy, rr) = centre(b);
let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
match mode {
SpherizeMode::Normal => {
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * sphere_1d(r / rr, a);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = (dx / hx, dy / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = sphere_1d(r, a) / r;
(cx + u * k * hx, cy + v * k * hy)
}
SpherizeMode::HorizontalOnly => {
if dx.abs() >= hx {
+6 -6
View File
@@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15;
struct Rng(u64);
impl Rng {
/// The next value of the SplitMix64 sequence.
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
crate::photo_util::splitmix64(&mut self.0)
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
@@ -700,11 +697,14 @@ mod parallel_tests {
.collect()
}
/// Blocks of random brightness: lots of strong corners, no libm in the making.
/// Blocks of random brightness: lots of strong corners, no libm in the making. Only the
/// recorded-hash test below uses it, and that one is Linux x86_64 only.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn blocks(w: usize, h: usize, seed: u64) -> Vec<f32> {
(0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect()
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn fnv(parts: &[u64]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for p in parts {
+3 -5
View File
@@ -65,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20;
#[derive(Clone)]
pub(crate) struct Rng(u64);
impl Rng {
/// A generator whose sequence is fixed by `seed`.
pub(crate) fn new(seed: u64) -> Self {
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
}
/// The next value of the SplitMix64 sequence.
pub(crate) fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
crate::photo_util::splitmix64(&mut self.0)
}
/// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]).
pub(crate) fn skip(&mut self, n: u64) {
+43 -5
View File
@@ -411,6 +411,14 @@ pub enum FilterParams {
},
// ---- Render ----
/// Clouds (or Difference Clouds, which subtracts them from the pixels) between two straight
/// sRGB RGBA colours; the cloud size follows the bounds.
Clouds {
seed: u32,
difference: bool,
foreground: [f32; 4],
background: [f32; 4],
},
/// Variance 1–64, strength 1–64; colours straight sRGB RGBA.
Fibers {
variance: f32,
@@ -482,7 +490,8 @@ pub enum FilterParams {
distribution: Distribution,
monochromatic: bool,
seed: u32,
/// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller.
/// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document
/// coordinates), supplied by the caller.
#[serde(skip)]
depth_map: Option<std::sync::Arc<Image>>,
},
@@ -605,7 +614,13 @@ impl FilterParams {
/// 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 { .. })
matches!(self, FilterParams::Fibers { .. } | FilterParams::Clouds { difference: false, .. })
}
/// Whether the filter places and sizes its pattern by the canvas even with a selection
/// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result.
pub fn sizes_by_canvas(&self) -> bool {
matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
}
/// Human-readable name.
@@ -652,6 +667,8 @@ impl FilterParams {
FilterParams::Displace { .. } => "Displace",
FilterParams::Shear { .. } => "Shear",
FilterParams::ZigZag { .. } => "ZigZag",
FilterParams::Clouds { difference: false, .. } => "Clouds",
FilterParams::Clouds { difference: true, .. } => "Difference Clouds",
FilterParams::Fibers { .. } => "Fibers",
FilterParams::LensFlare { .. } => "Lens Flare",
FilterParams::LightingEffects { .. } => "Lighting Effects",
@@ -696,7 +713,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::Wind { method, .. } => r(stylize2::wind_reach(*method)),
FilterParams::Displace { .. } | FilterParams::Shear { .. } | FilterParams::ZigZag { .. } | FilterParams::SpinBlur { .. } => Halo::Bounds,
// Generators and per-pixel renders only need the bounds geometry (always in the context).
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0),
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } | FilterParams::Clouds { .. } => Halo::Radius(0),
FilterParams::LightingEffects { .. } => r(1.0),
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
@@ -744,8 +761,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::AddNoise { amount, distribution, monochromatic, seed } => noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed),
FilterParams::Median { radius } => noise::median(src, out, *radius, None),
FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)),
FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve),
FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve),
FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve),
FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve),
FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined),
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
@@ -816,6 +833,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
}
FilterParams::Shear { points, undefined } => distort2::shear(src, out, ctx, points, *undefined),
FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style),
FilterParams::Clouds { seed, difference, foreground, background } => render::clouds(src, out, ctx, *seed, *difference, (*foreground, *background)),
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
}
@@ -909,6 +927,23 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
}
}
/// Minimum / Maximum in the document's polarity. CMYK samples are ink, so there Maximum spreads
/// paper white and Minimum spreads ink (as Photoshop does): the colour channels run inverted,
/// alpha keeps its usual polarity.
fn min_max_ink(src: &Image, out: Rect, ctx: &Ctx, radius: f32, max: bool, preserve: Preserve) -> Vec<f32> {
if !fxutil::subtractive(ctx) {
return other::min_max_preserve(src, out, radius, max, preserve);
}
let n = src.ch.max(1);
let cc = if ctx.alpha { n - 1 } else { n };
let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v));
let mut inv = src.clone();
flip(&mut inv.data);
let mut res = other::min_max_preserve(&inv, out, radius, max, preserve);
flip(&mut res);
res
}
/// Output tile size used by [`apply`].
pub const TILE: i32 = 256;
@@ -972,6 +1007,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
}
/// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence).
/// Nonpositive tile sizes are treated as one pixel.
pub fn apply_tiled(
surface: &Surface,
params: &FilterParams,
@@ -1014,6 +1050,8 @@ fn apply_tiled_with_impl(
ctl: &photocraft_raster::Interrupt,
prepare_fast_paths: bool,
) -> Option<Surface> {
// Every dispatch path needs a positive step, including Motion Blur's fallback.
let tile = tile.max(1);
if ctl.cancelled() {
return None;
}
+81 -19
View File
@@ -19,6 +19,7 @@
//! copied untouched. [`ProxyImage::render`] is the cheap preview path: bilinear field and
//! bilinear source on a downsampled RGBA copy, re-rendered only where a dab changed things.
use crate::par;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
@@ -186,6 +187,10 @@ struct StrokeCursor {
carry: f64,
}
/// Dabs over this many field nodes compute their rows in parallel (an 800 px brush has about
/// 125,000); smaller ones are not worth the hand-off.
const PARALLEL_DAB_NODES: usize = 16_384;
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
@@ -487,9 +492,11 @@ impl LiquifyField {
bilinear(&self.d, self.w, self.h, gx, gy)
}
};
let mut writes: Vec<(usize, [f32; 2], f32)> = Vec::with_capacity((i1 - i0 + 1) * (j1 - j0 + 1));
// Every node reads only the snapshot and the field as it was before this dab (the writes
// land after), so rows are independent: a large brush computes them in parallel.
let inv_cell = 1.0 / self.cell;
for j in j0..=j1 {
let node_row = |j: usize| -> Vec<(usize, [f32; 2], f32)> {
let mut row = Vec::with_capacity(i1 - i0 + 1);
for i in i0..=i1 {
let p = self.node_pos(i, j);
let (dx, dy) = (p[0] - c[0], p[1] - c[1]);
@@ -503,7 +510,7 @@ impl LiquifyField {
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
let f = self.freeze[idx];
let v = if s.tool == LiquifyTool::Freeze { (f as f64 + k_raw).min(1.0) } else { (f as f64 - k_raw).max(0.0) };
writes.push((idx, self.d[idx], v as f32));
row.push((idx, self.d[idx], v as f32));
continue;
}
let k = k_raw * (1.0 - f64::from(self.freeze[idx]));
@@ -553,10 +560,13 @@ impl LiquifyField {
[(q[0] + dq[0] - p[0]) as f32, (q[1] + dq[1] - p[1]) as f32]
}
};
writes.push((idx, new, self.freeze[idx]));
row.push((idx, new, self.freeze[idx]));
}
}
for (idx, d, f) in writes {
row
};
let rows = j1 - j0 + 1;
let writes = par::map(rows, rows * (i1 - i0 + 1) > PARALLEL_DAB_NODES, |row| node_row(j0 + row));
for (idx, d, f) in writes.into_iter().flatten() {
self.d[idx] = d;
self.freeze[idx] = f;
}
@@ -653,7 +663,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
if field.is_identity() || b.is_empty() {
return out;
}
let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3;
// A 1D Catmull-Rom stencil's absolute weights sum to at most 5/4, so the 2D
// interpolated displacement can reach 25/16 of the largest nodal displacement.
let maxd = ((f64::from(field.max_displacement()) * (25.0 / 16.0)).ceil() as i32).saturating_add(3);
let has_freeze = field.freeze.iter().any(|&f| f > 0.0);
let tiles: Vec<Rect> = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect();
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
@@ -670,7 +683,15 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
let wx = catmull_rom(gx - gx.floor());
let d = field.sample_cubic(gx, gy, &wx, &wy);
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
if has_freeze {
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
if mask > 0.0 {
let linear = field.sample(px, py);
d[0] = d[0] * (1.0 - mask) + linear[0] * mask;
d[1] = d[1] * (1.0 - mask) + linear[1] * mask;
}
}
if d[0] != 0.0 || d[1] != 0.0 {
moved = true;
}
@@ -920,18 +941,23 @@ impl ProxyImage {
return false;
}
source_scratch.resize(source_len, [0.0; 4]);
src.read_rgba_into(src_rect, source_scratch);
if !src.format().alpha {
for pixel in source_scratch.iter_mut() {
pixel[3] = 1.0;
// Read and premultiply the source in one pass per band of rows, the bands in parallel: at
// an 800 px brush it is about a million pixels (P30).
let opaque = !src.format().alpha;
const BAND_ROWS: usize = 32;
par::chunks_mut(source_scratch, sw * BAND_ROWS, true, |band_index, band| {
let y = src_rect.y0 + (band_index * BAND_ROWS) as i32;
src.read_rgba_into(Rect::new(src_rect.x0, y, src_rect.x1, y + (band.len() / sw) as i32), band);
for pixel in band.iter_mut() {
if opaque {
pixel[3] = 1.0;
}
let alpha = pixel[3];
pixel[0] *= alpha;
pixel[1] *= alpha;
pixel[2] *= alpha;
}
}
for pixel in source_scratch.iter_mut() {
let alpha = pixel[3];
pixel[0] *= alpha;
pixel[1] *= alpha;
pixel[2] *= alpha;
}
});
let x0 = (region.x0 - bounds.x0) as usize;
let x1 = (region.x1 - bounds.x0) as usize;
@@ -1376,6 +1402,42 @@ mod tests {
assert!(g.freeze.iter().all(|f| *f == 0.0));
}
#[test]
fn fully_frozen_pixels_keep_their_mapping_when_an_adjacent_area_is_warped() {
let bounds = bounds();
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for y in 0..bounds.height() {
for x in 0..bounds.width() {
src.write_pixel(x as i32, y as i32, &[x as f32 / bounds.width() as f32, y as f32 / bounds.height() as f32, 0.0, 1.0]);
}
}
let mut mask_only = LiquifyField::new(bounds, 2.0);
mask_only.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
assert_eq!(apply_liquify(&src, &mask_only), src);
let mut field = LiquifyField::new(bounds, 2.0);
let mut first_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
first_warp.points = vec![vec![22.0, 32.0], vec![34.0, 32.0]];
field.apply_stroke(&first_warp);
field.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
let frozen_pixel = (37, 32);
assert!(field.freeze_at(f64::from(frozen_pixel.0) + 0.5, f64::from(frozen_pixel.1) + 0.5) > 0.999);
let before = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
assert_ne!(before, src.pixel(frozen_pixel.0, frozen_pixel.1), "freezing must retain the earlier warp");
let neighbor_before = apply_liquify(&src, &field).pixel(43, 32);
let mut adjacent_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 24.0);
adjacent_warp.points = vec![vec![44.0, 32.0], vec![52.0, 32.0]];
field.apply_stroke(&adjacent_warp);
let after = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
let neighbor_after = apply_liquify(&src, &field).pixel(43, 32);
assert_eq!(before, after, "a fully frozen pixel must keep its pre-neighbor-warp mapping");
assert_ne!(neighbor_before, neighbor_after, "an adjacent unfrozen pixel must still move");
}
#[test]
fn twirl_pucker_bloat_and_push_left_have_the_right_sense() {
let at = |tool: LiquifyTool, pts: Vec<Vec<f64>>| {
+180 -3
View File
@@ -8,7 +8,8 @@ use crate::image::Image;
use crate::{Ctx, HsbModel};
/// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha
/// kept). Kernels shorter than 25 entries are zero-padded.
/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are
/// ink, so there it runs on `1 - ink` and a positive offset brightens.
pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -16,6 +17,7 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
(0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect();
let s = if scale == 0.0 { 1.0 } else { scale };
let off = offset / 255.0;
let sub = crate::fxutil::subtractive(ctx);
let mut res = src.crop(out);
if k.is_empty() {
return res;
@@ -23,8 +25,10 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
for (c, v) in px.iter_mut().enumerate().take(cc) {
let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum();
*v = sum / s + off;
// CMYK: convolve brightness (1 - ink), then back to ink.
let lum = |v: f32| if sub { 1.0 - v } else { v };
let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum();
*v = lum(sum / s + off);
}
}
res
@@ -120,6 +124,179 @@ pub(crate) fn hsb_hsl(src: &Image, out: Rect, ctx: &Ctx, input: HsbModel, output
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{PixelFormat, SampleType};
use photocraft_raster::Surface;
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
fn custom_fixture(depth: SampleType, mode: ColorMode, alpha: bool) -> (Surface, Rect) {
let b = Rect::new(-2, -1, 3, 4);
let format = PixelFormat::new(mode, depth, alpha);
let mut src = Surface::new(format);
let mut pixels = Vec::new();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
for c in 0..format.channels() {
pixels.push(((x - b.x0) + 5 * (y - b.y0) + 3 * c as i32) as f32 / 40.0);
}
}
}
src.write_region(b, &pixels);
(src, b)
}
fn assert_sample(actual: f32, expected: f32, depth: SampleType) {
let (expected, tolerance) = match depth {
SampleType::U8 => (expected.clamp(0.0, 1.0), 1.0 / 255.0),
SampleType::U16 => (expected.clamp(0.0, 1.0), 1.0 / 65535.0),
SampleType::F32 => (expected, 1e-6),
};
assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} != {expected}");
}
fn run_custom(src: &Surface, b: Rect, kernel: Vec<f32>, scale: f32, offset: f32) -> Surface {
crate::apply_in(src, &crate::FilterParams::Custom { kernel, scale, offset }, b, b, None, b)
}
#[test]
fn custom_all_25_taps_use_row_major_offsets_and_repeat_canvas_edges() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
for tap in 0..25 {
let mut kernel = vec![0.0; 25];
kernel[tap] = 1.0;
let out = run_custom(&src, b, kernel, 1.0, 0.0);
let (dx, dy) = (tap as i32 % 5 - 2, tap as i32 / 5 - 2);
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let expected = src.pixel((x + dx).clamp(b.x0, b.x1 - 1), (y + dy).clamp(b.y0, b.y1 - 1));
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], expected[c], depth);
}
assert_eq!(actual[3], src.pixel(x, y)[3], "tap={tap}, alpha must not shift");
}
}
}
}
}
#[test]
fn custom_short_kernel_zero_pads_and_ignores_entries_after_25() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
let mut short = vec![0.0; 13];
short[0] = 0.25;
short[12] = 0.75;
let mut padded = short.clone();
padded.resize(25, 0.0);
let mut long = padded.clone();
long.extend([1e6, -1e6, f32::NAN]);
let out = run_custom(&src, b, short, 1.0, 0.0);
assert_eq!(out.read_region(b), run_custom(&src, b, padded, 1.0, 0.0).read_region(b));
assert_eq!(out.read_region(b), run_custom(&src, b, long, 1.0, 0.0).read_region(b));
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let corner = src.pixel((x - 2).max(b.x0), (y - 2).max(b.y0));
let centre = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], 0.25 * corner[c] + 0.75 * centre[c], depth);
}
}
}
}
}
#[test]
fn custom_scale_zero_is_one_and_offset_is_in_255ths() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
let before = src.read_region(b);
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel.clone(), 0.0, -51.0);
assert_eq!(out.read_region(b), run_custom(&src, b, kernel, 1.0, -51.0).read_region(b));
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let original = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], 2.0 * original[c] - 0.2, depth);
}
assert_eq!(actual[3], original[3]);
}
}
assert_eq!(src.read_region(b), before);
}
}
#[test]
fn custom_neighbourhood_convolution_preserves_each_pixels_alpha() {
for depth in DEPTHS {
for mode in [ColorMode::Rgb, ColorMode::Cmyk] {
let (src, b) = custom_fixture(depth, mode, true);
let out = run_custom(&src, b, vec![1.0; 25], 25.0, 0.0);
let alpha = src.channels() - 1;
assert_ne!(out.pixel(b.x0, b.y0)[0], src.pixel(b.x0, b.y0)[0], "fixture must change colour");
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
assert_eq!(out.pixel(x, y)[alpha], src.pixel(x, y)[alpha], "{depth:?}, {mode:?}, {x},{y}");
}
}
}
}
}
#[test]
fn custom_cmyk_convolves_brightness_including_black_with_or_without_alpha() {
for depth in DEPTHS {
for alpha in [false, true] {
let (src, b) = custom_fixture(depth, ColorMode::Cmyk, alpha);
for offset in [-51.0, 51.0] {
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel, 1.0, offset);
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let original = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..4 {
// Twice the reflected brightness, plus offset, then back to ink.
assert_sample(actual[c], 2.0 * original[c] - 1.0 - offset / 255.0, depth);
}
if alpha {
assert_eq!(actual[4], original[4]);
}
}
}
}
}
}
}
#[test]
fn custom_float_retains_hdr_while_integer_storage_clamps() {
let b = Rect::new(0, 0, 1, 1);
for depth in DEPTHS {
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
src.fill_rect(b, &[0.0, 0.5, 1.0, 0.4]);
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel, 1.0, -51.0);
let actual = out.pixel(0, 0);
let original = src.pixel(0, 0);
for c in 0..3 {
assert_sample(actual[c], 2.0 * original[c] - 0.2, depth);
}
assert_eq!(actual[3], original[3]);
if depth == SampleType::F32 {
assert!(actual[0] < 0.0 && actual[2] > 1.0);
} else {
assert_eq!((actual[0], actual[2]), (0.0, 1.0));
}
}
}
#[test]
fn hsb_hsl_round_trip() {
+17 -4
View File
@@ -89,10 +89,7 @@ fn composite_area(
let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n;
let px = &mut region[i..i + n];
let d = photocraft_raster::to_rgba(&fmt, px);
let mut o = blend::composite(blend_mode, d, color(x, y), k);
if lock_transparency {
o[3] = d[3];
}
let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) };
from_rgba_into(&fmt, o, &mut enc);
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
if let Some(i) = inks
@@ -296,6 +293,22 @@ mod tests {
assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection");
}
#[test]
fn locked_gradient_blends_source_atop_on_semi_transparent_pixels() {
// Locked half-transparent white, flat black gradient at 50%: colour halfway, alpha kept (#5783).
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
for mode in [BlendMode::Normal, BlendMode::Multiply] {
let mut s = Surface::new(fmt);
let a = Rect::new(0, 0, 4, 1);
s.fill_rect(a, &[1.0, 1.0, 1.0, 0.5]);
let black = [0.0, 0.0, 0.0, 1.0];
paint_gradient(&mut s, a, (0.0, 0.0), (4.0, 0.0), GradientShape::Linear, &[(0.0, black), (1.0, black)], false, 0.5, mode, false, None, true);
let p = s.rgba(2, 0);
assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{fmt:?} {mode:?} {p:?}");
}
}
}
#[test]
fn bucket_fills_contiguous_region() {
let mut s = Surface::new(PixelFormat::RGBA8);
+203 -50
View File
@@ -897,14 +897,39 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
label[i] = img as i32;
}
}
if no == 0 || nn == no {
if no == 0 {
continue;
}
let (ox, oy) = (sx / no as f64, sy / no as f64);
let (cx, cy) = (nx / nn as f64, ny / nn as f64);
let contained = nn == no;
let (ox, oy) = if contained {
// Wholly inside the composite (#5745: two photos in one document, the second moved
// by Auto-Align so the canvas crops it to the part over the first). Its side of the
// seam is the one it lies towards from the composite's centre; an image centred on
// the composite has no side, and the earlier images keep it.
let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize);
for (i, l) in label.iter().enumerate() {
if *l >= 0 {
px += (i % w) as f64;
py += (i / w) as f64;
pn += 1;
}
}
let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64);
if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 {
continue;
}
(px, py)
} else {
(sx / no as f64, sy / no as f64)
};
let vertical = (cx - ox).abs() >= (cy - oy).abs();
// Image on the increasing side along the seam's cross axis?
let positive = if vertical { cx > ox } else { cy > oy };
// A contained image has no pixels of its own, so a seam on its outer edge would leave it
// nothing: break cost ties away from its side (towards larger k when it lies on the
// decreasing side) so it keeps its side of the overlap.
let prefer_high = contained && !positive;
let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() };
let (lines, len) = if vertical { (h, w) } else { (w, h) };
let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line };
@@ -925,7 +950,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
let mut best = (0.0f32, usize::MAX);
if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) {
best = (f32::INFINITY, usize::MAX);
for dk in [-1i64, 0, 1] {
let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] };
for dk in dks {
let kk = k as i64 + dk;
if kk < 0 || kk >= len as i64 {
continue;
@@ -952,7 +978,12 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
if seam[line] != usize::MAX {
continue;
}
let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue };
let Some(k) = (0..len)
.filter(|&k| overlap[idx(line, k)])
.min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) }))
else {
continue;
};
let (mut l, mut k) = (line, k);
loop {
seam[l] = k;
@@ -972,9 +1003,13 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
if s == usize::MAX {
continue;
}
let on_side = |k: usize| if positive { k > s } else { k < s };
// A contained image has no other cells, so where the overlap has nothing on its side
// of the seam (a line one cell wide) it takes the seam cell rather than nothing.
let take_seam = contained && !(0..len).any(|k| overlap[idx(line, k)] && on_side(k));
for k in 0..len {
let i = idx(line, k);
if overlap[i] && ((positive && k > s) || (!positive && k < s)) {
if overlap[i] && (on_side(k) || (take_seam && k == s)) {
label[i] = img as i32;
}
}
@@ -1170,10 +1205,28 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize
})
}
/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built
/// and accumulated in batches under it, so its peak memory doesn't grow with the image count
/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side
/// uses a many-core machine better than one band's row-parallel work, and one at a time made the
/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop.
#[cfg(not(target_arch = "wasm32"))]
const BAND_BUDGET: usize = 5 << 30;
/// Without threads the bands of a batch are built one after another anyway, so each is added as
/// soon as it is built (the browser's address space is 4 GB).
#[cfg(target_arch = "wasm32")]
const BAND_BUDGET: usize = 0;
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
/// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour
/// channels (`ch` per pixel) and the total coverage.
pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET)
}
/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one
/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it.
fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize, budget: usize) -> (Vec<f32>, Vec<f32>) {
let ch = imgs.first().map_or(1, |i| i.ch);
let levels = levels.max(1);
let align = 1i32 << (levels - 1);
@@ -1187,13 +1240,22 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
let bands = par_map(imgs.len(), |k| {
let im = &imgs[k];
let grid = |im: &RoiImage| {
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
(gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
};
// Building one band holds its grid's colour and weight planes about three times over: the
// pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top.
let cost = |im: &RoiImage| {
let (_, _, gw, gh) = grid(im);
gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
};
let band = |k: usize| -> PyramidBand {
let im = &imgs[k];
let (gx0, gy0, gw, gh) = grid(im);
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
// most one), so rows of the grid are independent and fill in parallel.
let src_of = |gx: usize, gy: usize| -> Option<usize> {
@@ -1225,68 +1287,85 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
}
});
let bin: Vec<f32> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
drop(cov);
push_pull(gw, gh, ch, &mut px, &bin);
// Laplacian of the colours, Gaussian of the weights.
drop(bin);
// Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather
// than being copied: with one image built at a time, a copy is a serial pass over it.
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
let mut gau: Vec<Vec<f32>> = Vec::new();
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
for l in 0..levels {
if l + 1 == levels {
lap.push((cw, chh, cur.clone()));
gau.push(cwt.clone());
lap.push((cw, chh, cur));
gau.push(cwt);
break;
}
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
let up = expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
gau.push(cwt.clone());
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
gau.push(cwt);
cw = nw;
chh = nh;
cur = down;
cwt = wdown;
}
(gx0, gy0, lap, gau)
});
// Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per
// level, bands one after another), but rows of a level are independent, so they run in
// parallel: every row walks the bands itself.
for l in 0..levels {
let pw = sizes[l].0;
par_rows(&mut wsum[l], pw, 1, |py, row| {
for b in &bands {
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
row[px] += k;
}
}
});
par_rows(&mut acc[l], pw, ch, |py, row| {
for b in &bands {
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
for c in 0..ch {
row[px * ch + c] += k * band[x * ch + c];
};
// Build the bands a batch at a time and add each batch before building the next. Each pixel
// adds the bands in the same order as the serial loop (per level, bands one after another;
// batches follow each other in image order), but rows of a level are independent, so they
// run in parallel: every row walks the batch's bands itself.
let mut start = 0;
while start < imgs.len() {
let mut end = start + 1;
let mut bytes = cost(&imgs[start]);
while let Some(im) = imgs.get(end)
&& bytes.saturating_add(cost(im)) <= budget
{
bytes = bytes.saturating_add(cost(im));
end += 1;
}
let bands = par_map(end - start, |j| band(start + j));
for l in 0..levels {
let pw = sizes[l].0;
par_rows(&mut wsum[l], pw, 1, |py, row| {
for b in &bands {
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
row[px] += k;
}
}
}
});
});
par_rows(&mut acc[l], pw, ch, |py, row| {
for b in &bands {
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
for c in 0..ch {
row[px * ch + c] += k * band[x * ch + c];
}
}
}
});
}
start = end;
}
for l in 0..levels {
let pw = sizes[l].0;
@@ -1541,6 +1620,58 @@ mod tests {
assert!(max_jump < 0.1, "max step {max_jump}");
assert!(cov.iter().all(|c| *c == 1.0));
}
#[test]
fn an_image_wholly_inside_the_composite_still_gets_its_side() {
// #5745: the first image covers the whole 120×40 grid, the second only its right part
// (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything.
let (w, h) = (120usize, 40usize);
let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]];
let cover: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()];
let labels = seam_labels(w, h, &luma, &cover, &[0, 1]);
for y in 0..h {
assert_eq!(labels[y * w + 5], 0, "row {y}");
assert_eq!(labels[y * w + w - 1], 1, "row {y}");
// The second image's region is one run at the right of its seam.
let row = &labels[y * w..(y + 1) * w];
let first = row.iter().position(|l| *l == 1).unwrap();
assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}");
}
// The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap
// instead of on the image's outer edge, which used to leave it nothing.
let left: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()];
let labels = seam_labels(w, h, &luma, &left, &[0, 1]);
for y in 0..h {
let row = &labels[y * w..(y + 1) * w];
assert_eq!(row[0], 1, "row {y}: {row:?}");
assert_eq!(row[w - 1], 0, "row {y}");
let last = row.iter().rposition(|l| *l == 1).unwrap();
assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}");
}
let (tw, th) = (40usize, 120usize);
let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]];
let top: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()];
let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]);
for x in 0..tw {
assert_eq!(labels[x], 1, "column {x}");
assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}");
}
// One column or row wide: the seam can only run through it, and the image keeps it.
for col in [0, w - 1] {
let strip: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w == col).collect()];
let labels = seam_labels(w, h, &luma, &strip, &[0, 1]);
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i % w == col)), "column {col}: {labels:?}");
}
let strip: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw == 0).collect()];
let labels = seam_labels(tw, th, &tluma, &strip, &[0, 1]);
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i / tw == 0)), "{labels:?}");
// An image centred on the composite has no side: the first image keeps the overlap.
let centred: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()];
assert!(seam_labels(w, h, &luma, &centred, &[0, 1]).iter().all(|l| *l == 0));
// Two images covering the same cells, likewise.
let same = vec![vec![true; w * h]; 2];
assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0));
}
}
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
@@ -1910,6 +2041,28 @@ mod parallel_tests {
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
}
/// Batching the bands (#7758) changes when they are built, not the result. Budgets from none
/// (one image per batch) up to everything in steps of a fifth pass through every split of the
/// three images.
#[test]
fn multiband_batches_match_the_serial_code() {
let budgets: Vec<usize> =
std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect();
for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() {
for ch in [1usize, 3] {
let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64);
for levels in [1usize, 3, 5] {
let want = ref_multiband(w, h, &imgs, &wts, levels);
for &budget in &budgets {
let got = multiband_batched(w, h, &imgs, &wts, levels, budget);
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}");
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}");
}
}
}
}
}
/// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so
/// they hold on every platform).
#[test]
+3
View File
@@ -182,6 +182,9 @@ pub enum DepthSource {
None,
Transparency,
LayerMask,
/// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the
/// caller like the layer mask.
Channel,
}
/// Built-in Shape Blur kernels.
+47 -14
View File
@@ -159,15 +159,21 @@ pub(crate) fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -
(0..n).map(f).collect()
}
/// Advances `state` one SplitMix64 step and returns the mixed output.
pub(crate) fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Deterministic 64-bit generator (SplitMix64).
pub(crate) struct Rng(pub u64);
impl Rng {
/// The next value of the SplitMix64 sequence.
pub fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
splitmix64(&mut self.0)
}
pub fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
@@ -183,15 +189,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 {
(z >> 40) as f32 / (1u64 << 24) as f32
}
/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1).
pub(crate) fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// sRGB transfer function, encode.
pub(crate) fn linear_to_srgb(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear};
/// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges.
pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 {
@@ -217,6 +215,41 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] {
mod tests {
use super::*;
/// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit.
#[test]
fn splitmix64_output_is_unchanged() {
/// The SplitMix64 step as each generator inlined it before.
fn inlined(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
let mut zero = 0;
assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]);
for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] {
let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed));
for _ in 0..10_000 {
let want = inlined(&mut copy);
assert_eq!(splitmix64(&mut shared), want);
assert_eq!(rng.next(), want);
}
}
}
/// The shared sRGB functions match the removed copies bit for bit.
#[test]
fn srgb_transfer_output_is_unchanged() {
let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
for i in -2000..=6000 {
let v = i as f32 / 4000.0;
assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}");
assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}");
}
}
#[test]
fn dense_and_sparse_solvers_agree() {
// Overdetermined line fit y = 2x + 1.
+478 -26
View File
@@ -61,8 +61,15 @@ pub enum Dither {
Noise,
}
/// `(v.clamp(0, 1) * 255).round() as u8`, without the libm `roundf` call per channel (that call
/// was about a tenth of the palette scan on a large image). `x` is in 0..=255, so truncating
/// with an integer cast and adding one when the exact remainder is at least a half is
/// round-half-away-from-zero, the same as `f32::round`; NaN gives 0 in both. The
/// `to8_matches_round_for_every_float` test checks every input.
fn to8(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
let x = v.clamp(0.0, 1.0) * 255.0;
let t = x as u32;
(t + u32::from(x - t as f32 >= 0.5)) as u8
}
fn rgb8(p: &[f32; 4]) -> Rgb8 {
@@ -166,15 +173,116 @@ fn forced_colors(f: Forced) -> Vec<Rgb8> {
}
}
/// Unique 8-bit colours with counts (opaque pixels only).
/// Unique 8-bit colours with counts (opaque pixels only), sorted by colour.
fn histogram(px: &[[f32; 4]]) -> Vec<(Rgb8, u32)> {
let mut m: std::collections::HashMap<Rgb8, u32> = std::collections::HashMap::new();
for p in px.iter().filter(|p| p[3] >= 0.5) {
*m.entry(rgb8(p)).or_default() += 1;
// Pack each opaque pixel into one u32 (r, g, b from the top, so the numeric order is the
// `[u8; 3]` order), sort the plain integers and count the runs. This replaces a SipHash
// `HashMap` insert per pixel: counts are integers, so any order of work gives the same
// result, and equal colours sit next to each other after the sort. One 4-byte value per
// pixel is the only large allocation (no per-thread tables).
let pack = |p: &[f32; 4]| {
let [r, g, b] = rgb8(p);
u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b)
};
#[cfg(not(target_arch = "wasm32"))]
let keys = {
use rayon::prelude::*;
let mut keys: Vec<u32> = px.par_iter().filter(|p| p[3] >= 0.5).map(pack).collect();
keys.par_sort_unstable();
keys
};
#[cfg(target_arch = "wasm32")]
let keys = {
let mut keys: Vec<u32> = px.iter().filter(|p| p[3] >= 0.5).map(pack).collect();
keys.sort_unstable();
keys
};
let mut out: Vec<(Rgb8, u32)> = Vec::new();
for k in keys {
let c = [(k >> 16) as u8, (k >> 8) as u8, k as u8];
match out.last_mut() {
Some((last, n)) if *last == c => *n = n.saturating_add(1),
_ => out.push((c, 1)),
}
}
out
}
/// Widest axis of `b` and its split score (weighted extent), or `None` for a box that cannot
/// be split. Every score is bit for bit what the old per-iteration scan gave.
fn split_score(b: &[([f32; 3], u32)]) -> Option<(usize, f32)> {
if b.len() <= 1 {
return None;
}
// One pass per axis, then the weight sum: the same folds the old per-iteration scan ran
// (measured faster than a single fused pass, which the compiler does not vectorise).
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
Some((best.0, best.1 * w.sqrt()))
}
/// Maps an f32 to a u32 whose unsigned order is exactly `f32::total_cmp` order.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits();
// Negative: flip every bit (reverses their order, puts them below). Positive: set the sign bit.
bits ^ ((((bits as i32) >> 31) as u32) | 0x8000_0000)
}
/// Stable LSD radix sort of `b` along `axis`, in the order of `f32::total_cmp`. Stable like
/// `sort_by`, so ties keep their original order and the result is element for element the same;
/// it is several times faster than a comparison sort on millions of points.
fn radix_sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
let n = b.len();
let Some(&fill) = b.first() else { return };
if n < 2 {
return;
}
// Three digits of 11, 11 and 10 bits; count all of them in one read.
let mut counts = [[0u32; 2048]; 3];
for e in b.iter() {
let k = total_order_key(e.0[axis]);
counts[0][(k & 0x7ff) as usize] += 1;
counts[1][((k >> 11) & 0x7ff) as usize] += 1;
counts[2][(k >> 22) as usize] += 1;
}
let mut src = std::mem::take(b);
let mut dst = vec![fill; n];
for (pass, count) in counts.iter_mut().enumerate() {
// A digit that is the same for every point would not move anything.
if count.iter().any(|&c| c as usize == n) {
continue;
}
let mut sum = 0u32;
for c in count.iter_mut() {
let here = *c;
*c = sum;
sum += here;
}
let shift = 11 * pass as u32;
for e in &src {
let d = ((total_order_key(e.0[axis]) >> shift) & 0x7ff) as usize;
dst[count[d] as usize] = *e;
count[d] += 1;
}
std::mem::swap(&mut src, &mut dst);
}
*b = src;
}
/// Stable sort of `b` along `axis`. Boxes this small are quicker with `sort_by` than with the
/// radix sort's fixed counting cost.
fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
if b.len() >= 256 {
radix_sort_along(b, axis);
} else {
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
}
let mut v: Vec<(Rgb8, u32)> = m.into_iter().collect();
v.sort_unstable();
v
}
/// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means.
@@ -183,29 +291,23 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
return Vec::new();
}
let mut boxes: Vec<Vec<([f32; 3], u32)>> = vec![pts.to_vec()];
// Each box's (axis, score), kept in lockstep with `boxes` (same swap_remove and pushes).
// Only the two boxes made by a split change, so only those are scanned again; rescanning
// every box on every iteration was most of the time here.
let mut scores: Vec<Option<(usize, f32)>> = vec![split_score(pts)];
while boxes.len() < n {
// Split the box with the largest weighted extent.
let (bi, axis, score) = boxes
// Split the box with the largest weighted extent (first one wins a tie, as before).
let (bi, axis, score) = scores
.iter()
.enumerate()
.filter(|(_, b)| b.len() > 1)
.map(|(i, b)| {
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
(i, best.0, best.1 * w.sqrt())
})
.filter_map(|(i, s)| s.map(|(axis, score)| (i, axis, score)))
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
if bi == usize::MAX || score <= 0.0 {
break;
}
let mut b = boxes.swap_remove(bi);
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
scores.swap_remove(bi);
sort_along(&mut b, axis);
let total: u64 = b.iter().map(|p| u64::from(p.1)).sum();
let mut acc = 0u64;
let mut cut = 1;
@@ -217,6 +319,8 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
}
}
let rest = b.split_off(cut);
scores.push(split_score(&b));
scores.push(split_score(&rest));
boxes.push(b);
boxes.push(rest);
}
@@ -239,8 +343,18 @@ fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
let lookup = Nearest::new(centers.iter().copied());
let mut sum = vec![[0.0f64; 3]; centers.len()];
let mut cnt = vec![0.0f64; centers.len()];
for (p, w) in pts {
let i = lookup.find(*p);
// The nearest-centre search is the expensive part and has no cross-point state, so it
// runs in parallel. The f64 sums below stay sequential in the original point order:
// float addition is not associative, and this keeps every bit of the result unchanged.
#[cfg(not(target_arch = "wasm32"))]
let nearest: Vec<u32> = {
use rayon::prelude::*;
pts.par_iter().map(|(p, _)| lookup.find(*p) as u32).collect()
};
#[cfg(target_arch = "wasm32")]
let nearest: Vec<u32> = pts.iter().map(|(p, _)| lookup.find(*p) as u32).collect();
for ((p, w), &i) in pts.iter().zip(&nearest) {
let i = i as usize;
for a in 0..3 {
sum[i][a] += f64::from(p[a]) * f64::from(*w);
}
@@ -258,6 +372,20 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 {
photocraft_color::convert::lab_to_srgb(lab).map(to8)
}
/// Histogram colours as CIE Lab points with their counts. Each point depends only on its own
/// colour and `collect` keeps the order, so the parallel map gives the same list; the log/pow
/// in `srgb_to_lab` made this a visible serial cost with millions of unique colours.
fn lab_points(hist: &[(Rgb8, u32)]) -> Vec<([f32; 3], u32)> {
let lab = |(c, n): &(Rgb8, u32)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n);
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
hist.par_iter().map(lab).collect()
}
#[cfg(target_arch = "wasm32")]
hist.iter().map(lab).collect()
}
/// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed
/// kinds; forced colours are included first and count toward it.
pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result<Vec<Rgb8>, String> {
@@ -296,7 +424,7 @@ pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced:
if hist.len() <= room {
hist.iter().map(|h| h.0).collect()
} else {
let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect();
let pts = lab_points(&hist);
let mut centers = median_cut(&pts, room);
kmeans(&pts, &mut centers, 4);
let mut v: Vec<Rgb8> = centers.iter().map(|c| lab_to_rgb8(*c)).collect();
@@ -562,6 +690,46 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
}
}
/// The threshold map of Bitmap's Custom Pattern from a pattern's lightness (0..=1, row-major):
/// pattern pixels ranked by lightness, ties sharing their average rank, the lightest pixel
/// thresholding lowest (it turns white first). A map of `n` pixels thus spreads `n` evenly
/// spaced levels `(k + 0.5) / n`, whatever the pattern's own contrast, as Photoshop's does
/// (measured: a 2x2 pattern of 32/96/160/224, a 3x1 of 50/128/200 and a 7x1 ramp all give
/// exactly those levels).
pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec<f32> {
let n = lightness.len();
let key = |i: usize| lightness.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|a, b| key(*a).total_cmp(&key(*b)));
let mut out = vec![0.5; n];
let mut start = 0;
while start < n {
let v = order.get(start).map(|i| key(*i));
let end = (start..n).find(|&j| order.get(j).map(|i| key(*i)) != v).unwrap_or(n);
// Ranks start..end share their mean.
let rank = (start + end - 1) as f32 / 2.0;
let t = 1.0 - (rank + 0.5) / n as f32;
for i in order.get(start..end).unwrap_or_default() {
if let Some(o) = out.get_mut(*i) {
*o = t;
}
}
start = end;
}
out
}
/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it
/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the
/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) {
let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
for (i, v) in gray.iter_mut().enumerate() {
let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 };
*v = if *v >= t { 1.0 } else { 0.0 };
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -570,6 +738,253 @@ mod tests {
(0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect()
}
fn to8_reference(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
#[test]
fn to8_matches_round_on_samples_and_edges() {
// Every half-way point and its neighbours, plus a dense sweep of the unit range.
for k in 0..=256u32 {
let mid = (k as f32 + 0.5) / 255.0;
for d in -3i32..=3 {
let v = f32::from_bits((mid.to_bits() as i32 + d) as u32);
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
}
for i in 0..200_000u32 {
let v = f32::from_bits(i * 20_000);
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, 1.0, -1.0, 2.0, 1e30, -1e30, f32::MIN_POSITIVE, 1e-45] {
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
#[ignore = "checks all 2^32 floats, a few seconds in release; run with --release --ignored"]
fn to8_matches_round_for_every_float() {
use rayon::prelude::*;
let bad = (0..=u32::MAX).into_par_iter().filter(|&b| to8(f32::from_bits(b)) != to8_reference(f32::from_bits(b))).count();
assert_eq!(bad, 0);
}
/// A repeatable 96x64 image: a gradient with up to 31 levels of integer-hash noise and a
/// few see-through pixels.
fn golden_image() -> Vec<[f32; 4]> {
let (w, h) = (96usize, 64usize);
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let noise = |salt: u32| ((((i as u32).wrapping_mul(2_654_435_761).wrapping_add(salt.wrapping_mul(40_503))) >> 13) & 31) as f32 / 255.0;
let alpha = if i % 17 == 0 { 0.25 } else { 1.0 };
[x / (w - 1) as f32 * 0.8 + noise(1), y / (h - 1) as f32 * 0.7 + noise(2), ((x + y) / (w + h) as f32) * 0.6 + noise(3), alpha]
})
.collect()
}
/// Palettes written down from the version before the speed-up (hash-map histogram, every box
/// rescanned each split, comparison sort, serial k-means). Any change to how the palettes
/// are built has to give these exact colours.
#[test]
fn palettes_match_the_recorded_originals() {
let px = golden_image();
#[rustfmt::skip]
let cases: Vec<(PaletteKind, usize, Forced, &[Rgb8])> = vec![
(
PaletteKind::Adaptive,
16,
Forced::None,
&[
[40, 173, 80], [196, 175, 150], [92, 173, 103], [92, 81, 71], [195, 83, 118], [39, 82, 49],
[142, 82, 95], [195, 37, 101], [143, 36, 79], [40, 128, 64], [196, 129, 133], [92, 127, 87],
[91, 36, 56], [38, 35, 33], [144, 128, 111], [144, 174, 127],
],
),
(
PaletteKind::Perceptual,
16,
Forced::None,
&[
[104, 111, 87], [51, 129, 68], [48, 178, 79], [110, 74, 77], [165, 46, 93], [205, 50, 107],
[200, 154, 143], [44, 54, 42], [190, 101, 121], [61, 32, 44], [155, 115, 111], [48, 93, 56],
[119, 42, 71], [84, 172, 98], [126, 165, 115], [172, 168, 135],
],
),
(
PaletteKind::Selective,
24,
Forced::BlackWhite,
&[
[0, 0, 0], [255, 255, 255], [102, 107, 85], [65, 69, 55], [87, 133, 89], [109, 73, 76],
[164, 42, 92], [204, 51, 102], [199, 153, 142], [54, 170, 84], [199, 107, 126], [65, 33, 45],
[153, 102, 102], [187, 180, 145], [40, 106, 54], [100, 176, 107], [138, 174, 123], [50, 188, 80],
[32, 37, 33], [47, 139, 69], [144, 143, 116], [41, 80, 49], [108, 33, 64], [151, 70, 94],
],
),
(
PaletteKind::Adaptive,
8,
Forced::Primaries,
&[
[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255],
[255, 0, 255], [255, 255, 0],
],
),
(
PaletteKind::Perceptual,
12,
Forced::BlackWhite,
&[
[0, 0, 0], [255, 255, 255], [104, 106, 85], [137, 43, 79], [148, 170, 126], [181, 144, 132],
[66, 172, 87], [88, 49, 60], [57, 118, 67], [42, 54, 41], [183, 101, 118], [196, 50, 105],
],
),
];
for (kind, colors, forced, expected) in cases {
assert_eq!(build_palette(&px, kind, colors, forced).unwrap(), expected, "{kind:?} {colors} {forced:?}");
}
}
/// The pre-speed-up median cut and k-means, kept as the oracle for the faster ones.
fn median_cut_reference(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
if pts.is_empty() || n == 0 {
return Vec::new();
}
let mut boxes: Vec<Vec<([f32; 3], u32)>> = vec![pts.to_vec()];
while boxes.len() < n {
let (bi, axis, score) = boxes
.iter()
.enumerate()
.filter(|(_, b)| b.len() > 1)
.map(|(i, b)| {
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
(i, best.0, best.1 * w.sqrt())
})
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
if bi == usize::MAX || score <= 0.0 {
break;
}
let mut b = boxes.swap_remove(bi);
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
let total: u64 = b.iter().map(|p| u64::from(p.1)).sum();
let mut acc = 0u64;
let mut cut = 1;
for (i, p) in b.iter().enumerate() {
acc += u64::from(p.1);
if acc * 2 >= total {
cut = (i + 1).clamp(1, b.len() - 1);
break;
}
}
let rest = b.split_off(cut);
boxes.push(b);
boxes.push(rest);
}
boxes
.iter()
.map(|b| {
let w: f64 = b.iter().map(|p| f64::from(p.1)).sum();
std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::<f64>() / w) as f32)
})
.collect()
}
fn kmeans_reference(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
for _ in 0..iters {
let lookup = Nearest::new(centers.iter().copied());
let mut sum = vec![[0.0f64; 3]; centers.len()];
let mut cnt = vec![0.0f64; centers.len()];
for (p, w) in pts {
let i = lookup.find(*p);
for a in 0..3 {
sum[i][a] += f64::from(p[a]) * f64::from(*w);
}
cnt[i] += f64::from(*w);
}
for (i, c) in centers.iter_mut().enumerate() {
if cnt[i] > 0.0 {
*c = std::array::from_fn(|a| (sum[i][a] / cnt[i]) as f32);
}
}
}
}
/// Points with many repeated coordinates (ties), negative values, -0.0 and weights up to
/// a few thousand, so the stable-sort and float-order rules are all exercised.
fn tie_heavy_points(n: usize, seed: u64) -> Vec<([f32; 3], u32)> {
let mut s = seed;
let mut next = move || {
s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(s >> 33) as u32
};
(0..n)
.map(|_| {
let mut c = |range: u32| match next() % 40 {
0 => -0.0,
1 => 0.0,
_ => (next() % range) as f32 * 0.5 - (range / 4) as f32,
};
([c(24), c(300), c(150)], 1 + next() % 3000)
})
.collect()
}
#[test]
fn faster_median_cut_and_kmeans_equal_the_originals_bit_for_bit() {
// 40 000 points cross the 256-point radix threshold on the first splits.
for (n, boxes, seed) in [(40_000, 256, 1), (40_000, 37, 2), (3_000, 256, 3), (200, 64, 4), (2, 8, 5)] {
let pts = tie_heavy_points(n, seed);
let new = median_cut(&pts, boxes);
let old = median_cut_reference(&pts, boxes);
assert_eq!(new.len(), old.len());
for (a, b) in new.iter().zip(&old) {
assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "median cut, n={n} boxes={boxes}");
}
let (mut a, mut b) = (new, old);
kmeans(&pts, &mut a, 3);
kmeans_reference(&pts, &mut b, 3);
for (x, y) in a.iter().zip(&b) {
assert_eq!(x.map(f32::to_bits), y.map(f32::to_bits), "kmeans, n={n} boxes={boxes}");
}
}
}
#[test]
fn radix_sort_is_the_stable_total_cmp_sort() {
for axis in 0..3 {
let mut pts = tie_heavy_points(5_000, 9 + axis as u64);
// The weight field doubles as an id: equal keys must keep their input order.
for (i, p) in pts.iter_mut().enumerate() {
p.1 = i as u32;
}
pts[10].0[axis] = f32::NAN;
pts[11].0[axis] = -f32::NAN;
pts[12].0[axis] = f32::INFINITY;
pts[13].0[axis] = f32::NEG_INFINITY;
let mut expected = pts.clone();
expected.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
radix_sort_along(&mut pts, axis);
let bits = |v: &[([f32; 3], u32)]| v.iter().map(|p| (p.0.map(f32::to_bits), p.1)).collect::<Vec<_>>();
assert_eq!(bits(&pts), bits(&expected), "axis {axis}");
}
}
#[test]
fn histogram_counts_opaque_pixels_sorted() {
let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.5], [1.0, 0.0, 0.0, 0.49], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]];
assert_eq!(histogram(&px), vec![([0, 0, 0], 1), ([0, 0, 255], 2), ([255, 0, 0], 2)]);
assert!(histogram(&[]).is_empty());
}
#[test]
fn fixed_palettes_have_expected_sizes() {
assert_eq!(web_palette().len(), 216);
@@ -722,4 +1137,41 @@ mod tests {
}
}
}
/// The first 8-bit gray level (of a 0..=255 ramp) that `t` turns white.
fn first_white(t: f32) -> u32 {
(0..=255).find(|v| *v as f32 / 255.0 >= t).unwrap_or(256)
}
#[test]
fn custom_pattern_thresholds_match_photoshop() {
// Photoshop (Image › Mode › Bitmap, Custom Pattern) on a 0..255 ramp: the first white
// level per pattern pixel, measured against defined patterns.
let l = |v: &[u8]| v.iter().map(|v| f32::from(*v) / 255.0).collect::<Vec<_>>();
let ramp7 = custom_pattern_thresholds(&l(&[0, 40, 80, 120, 160, 200, 240]));
assert_eq!(ramp7.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [237, 201, 164, 128, 92, 55, 19]);
let five = custom_pattern_thresholds(&l(&[10, 200, 90, 250, 150]));
assert_eq!(five.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [230, 77, 179, 26, 128]);
// Equal pixels share their rank: 100, 100 both sit at 50%.
let ties = custom_pattern_thresholds(&l(&[100, 100, 200, 50]));
assert_eq!(ties.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [128, 128, 32, 224]);
assert!(custom_pattern_thresholds(&[]).is_empty());
assert_eq!(custom_pattern_thresholds(&[f32::NAN, 0.3]).len(), 2);
}
#[test]
fn custom_pattern_tiles_from_the_top_left() {
// A 2x1 map: even columns need 75%, odd ones 25%.
let map = [0.75, 0.25];
let mut g = vec![0.5; 6];
to_bitmap_custom(&mut g, 3, &map, 2, 1);
assert_eq!(g, [0.0, 1.0, 0.0, 0.0, 1.0, 0.0]);
// A map that doesn't match its size, or an empty image width, is a 50% threshold.
let mut g = vec![0.4, 0.6];
to_bitmap_custom(&mut g, 2, &map, 3, 1);
assert_eq!(g, [0.0, 1.0]);
let mut g = vec![0.6];
to_bitmap_custom(&mut g, 0, &map, 2, 1);
assert_eq!(g, [1.0]);
}
}
Loaded 100 of 1095 files, more files were not shown because too many files have changed in this diff. Show more