diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000000..2408084cf9
--- /dev/null
+++ b/.coderabbit.yaml
@@ -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]"
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..2c4b8cb1a4
--- /dev/null
+++ b/.github/dependabot.yml
@@ -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)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4303f0aac4..b06e15449d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 67e6b31894..dca0dacfbb 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -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
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 970c6577a4..56d1ad0831 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -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
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587a8..01cb9cd663 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -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
diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml
index 10d22292d2..4b09278804 100644
--- a/.github/workflows/perf-nightly.yml
+++ b/.github/workflows/perf-nightly.yml
@@ -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
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 0000000000..ecee45a6d8
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 97b5ccdbc0..172bc7bd70 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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//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 --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p --test ` 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).
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 1ae19efa66..d8244b4fec 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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) | | 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 | | 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 | (`icons/.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 | (`icons/.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 | 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 `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 `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 `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 `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 `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 `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 `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 | | 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 |
diff --git a/Cargo.lock b/Cargo.lock
index 0167dea3b1..98e1290c29 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 4716812f03..b4a66136b8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/Dockerfile b/Dockerfile
index 66734f255a..1803d3a2e9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"; \
diff --git a/README.md b/README.md
index 973fb5330d..74a5c78f36 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,10 @@
+
+
+
+
PhotoCraft
@@ -20,6 +24,9 @@
+
+
+
@@ -199,7 +206,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
🗂️ Formats
- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), 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 .pcraft 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 codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) 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), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), 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 .pcraft 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 codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) 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.
🪄 The everyday essentials
@@ -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--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--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. `` 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. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
diff --git a/ROADMAP.md b/ROADMAP.md
index f41b71bfc9..829ff75af8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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 |
diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml
index f4848f379a..70f15d5be1 100644
--- a/apps/photocraft-cli/Cargo.toml
+++ b/apps/photocraft-cli/Cargo.toml
@@ -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"
diff --git a/apps/photocraft-cli/build.rs b/apps/photocraft-cli/build.rs
index c757a4a7c5..3951c46b81 100644
--- a/apps/photocraft-cli/build.rs
+++ b/apps/photocraft-cli/build.rs
@@ -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
diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs
index 03001ab02d..c2497e1bf1 100644
--- a/apps/photocraft-cli/src/lib.rs
+++ b/apps/photocraft-cli/src/lib.rs
@@ -15,19 +15,27 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
- photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli convert [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
- TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
+ An that is the 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 [--compact]
Print the document as JSON (size, mode, depth, layer tree).
- photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ]
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 [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+ photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ]
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 {
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 {
.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 ".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 ")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?);
- 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);
+ }
}
diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/it/cli.rs
similarity index 75%
rename from apps/photocraft-cli/tests/cli.rs
rename to apps/photocraft-cli/tests/it/cli.rs
index 3c28a8196e..c31239d14f 100644
--- a/apps/photocraft-cli/tests/cli.rs
+++ b/apps/photocraft-cli/tests/it/cli.rs
@@ -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::>()), "{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 = 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 = 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}");
+}
+
+/// ` --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::>();
+ 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 = 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();
+}
diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs
new file mode 100644
index 0000000000..581c983b3c
--- /dev/null
+++ b/apps/photocraft-cli/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod cli;
+mod orientation;
diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/it/orientation.rs
similarity index 100%
rename from apps/photocraft-cli/tests/orientation.rs
rename to apps/photocraft-cli/tests/it/orientation.rs
diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml
index dd12341af2..7443ead089 100644
--- a/apps/photocraft-web-boot/Cargo.toml
+++ b/apps/photocraft-web-boot/Cargo.toml
@@ -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"] }
diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs
index 56c2bf1517..c7dc50d9d5 100644
--- a/apps/photocraft-web-boot/src/main.rs
+++ b/apps/photocraft-web-boot/src/main.rs
@@ -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> {
(!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 {
+ 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>);
@@ -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