Merge remote-tracking branch 'upstream/main' into feat/cr2-sraw

# Conflicts:
#	crates/raw/src/lib.rs
This commit is contained in:
texjer committed 2026-11-04 22:01:15 -05:00
commit 7ce4804b76
1187 files changed
+204738 -11170

No files matched your search

+23 -4
View File
@@ -19,10 +19,29 @@ gpu = { max-threads = 4 }
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
test-group = 'gpu'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
# at the end of the log. The default profile's GPU override still applies.
# CI (`--profile ci`, .github/workflows/ci.yml). Collect all failures in one integration run
# instead of exposing one regression per rebuild. A hanging test still terminates, and every
# failure is repeated at the end of the log. The default profile's GPU override still applies.
[profile.ci]
fail-fast = true
fail-fast = false
slow-timeout = { period = "60s", terminate-after = 10 }
failure-output = "immediate-final"
# Keep phase timings for the remaining expensive Windows GPU regressions visible. This does
# not relax their timeout, isolate failures differently or remove any test cases.
# Page eviction and budget borrowing redraw multi-effect documents. On the Windows
# CPU-backed adapter it uses all GPU slots, avoiding oversubscription without skipping work.
[[profile.ci.overrides]]
platform = 'cfg(target_os = "windows")'
filter = 'package(photocraft-gpu) & test(/pages_are_evicted_under_the_budget|layer_pages_that_just_miss_borrow_the_effect_map_share/)'
threads-required = 4
success-output = "immediate"
[[profile.ci.overrides]]
platform = 'cfg(target_os = "windows")'
filter = 'package(photocraft-gpu) & test(/documents_larger_than_the_texture_limit|layer_effects_update_incrementally|pages_are_evicted_under_the_budget|the_focused_view_stays_resident_after_a_full_refresh/)'
success-output = "immediate"
# These multi-page effect regressions saturate the Windows software GPU. Reserve two
# scheduling slots so four independent devices do not compete for the same CPU renderer.
# All cases and the 60-second warning remain unchanged.
threads-required = 2
+183 -1
View File
@@ -11,6 +11,26 @@ on:
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
full_sweep:
description: "Run every testkit sweep case (runtime comparison and integration validation)"
type: boolean
default: false
color_range:
description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)"
type: boolean
default: false
selection_combining:
description: "Compare direct GRAY8 selection combining on 36 MP (Linux, exact outputs)"
type: boolean
default: false
brush_tip:
description: "Compare cached round-tip rasterization on 24 MP"
type: boolean
default: false
brush_tip_base:
description: "Baseline commit for the round-tip comparison"
type: string
default: "main"
selection_morphology:
description: "Compare selection morphology on 36 MP (Linux, exact outputs)"
type: boolean
@@ -22,6 +42,10 @@ on:
fuzz_seconds:
description: "Seconds per fuzz target"
default: "600"
dry_mixer:
description: "Compare the live Mixer Brush against the origin/main merge base on 24 MP (Linux)"
type: boolean
default: false
# The token is only used to check out the code: no job writes to the repository.
permissions:
@@ -30,6 +54,7 @@ permissions:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
PHOTOCRAFT_FULL_SWEEP: ${{ inputs.full_sweep && '1' || '0' }}
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
# bypasses that, lp fails loudly on a printer that does not exist.
PHOTOCRAFT_NO_PRINT: "1"
@@ -133,6 +158,7 @@ jobs:
# Cargo.toml's dev profile already keeps line tables only.
dev-debug: ""
env: |
PHOTOCRAFT_FULL_SWEEP=${{ inputs.full_sweep && '1' || '0' }}
PHOTOCRAFT_NO_PRINT=1
LPDEST=photocraft-no-such-printer
timeout-minutes: 100
@@ -424,6 +450,36 @@ jobs:
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
color-range-performance:
if: inputs.color_range == true
runs-on: ubuntu-latest
timeout-minutes: 45
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: color-range-perf
- name: Compare exact outputs and release timings on 24 MP
run: |
set -o pipefail
cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: color-range-performance
path: color-range-perf.txt
selection-morphology:
if: inputs.selection_morphology == true
runs-on: ubuntu-latest
@@ -459,7 +515,8 @@ jobs:
path: selection-morphology-perf.txt
selection-morphology-quick-perf:
if: inputs.selection_morphology == true
if: inputs.selection_morphology == true || inputs.color_range == true || inputs.selection_combining == true || inputs.brush_tip == true || inputs.dry_mixer == true
name: quick performance suite
# Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by
# bevel_bench, so its required GPU rows cannot be measured there.
runs-on: macos-15
@@ -481,3 +538,128 @@ jobs:
path: |
target/perf/*.json
target/perf/summary.md
selection-combining:
if: inputs.selection_combining == true
runs-on: ubuntu-latest
timeout-minutes: 30
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: selection-combining-perf
- name: Compare exact outputs and release timings on 36 MP
run: |
set -o pipefail
cargo test --release -p photocraft-algo --lib gray8_combining_release_comparison -- --ignored --nocapture | tee selection-combining-perf.txt
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: selection-combining-performance
path: selection-combining-perf.txt
brush-tip:
if: inputs.brush_tip == true
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
RAYON_NUM_THREADS: "4"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
path: after
- uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ inputs.brush_tip_base }}
path: before
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: |
before -> target
after -> target
key: brush-tip-release
- name: Build the same benchmark against both versions
run: |
cp after/crates/paint/examples/big_dab_bench.rs before/crates/paint/examples/big_dab_bench.rs
cargo build --release --locked --manifest-path before/Cargo.toml -p photocraft-paint --example big_dab_bench
cargo build --release --locked --manifest-path after/Cargo.toml -p photocraft-paint --example big_dab_bench
- name: Compare round and transformed tips on 24 MP
run: |
set -o pipefail
for size in 200 2000; do
for angle in 0 0.7; do
for version in before after; do
echo "$version: size=$size angle=$angle" | tee -a brush-tip.txt
"$version/target/release/examples/big_dab_bench" "$size" 0.37 "$angle" | tee -a brush-tip.txt
done
done
done
- uses: actions/upload-artifact@v7
if: always()
with:
name: brush-tip-performance
path: brush-tip.txt
dry-mixer:
if: inputs.dry_mixer == true
runs-on: ubuntu-latest
timeout-minutes: 90
env:
RAYON_NUM_THREADS: "4"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Pin baseline to the merge base with origin/main
id: baseline
run: |
git remote add upstream https://github.com/storytold/photocraft.git
git fetch --no-tags upstream main
base=$(git merge-base HEAD upstream/main)
echo "sha=$base" >> "$GITHUB_OUTPUT"
git worktree add --detach "$RUNNER_TEMP/photocraft-baseline" "$base"
mkdir -p "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples"
cp crates/engine/examples/bench_dry_mixer.rs "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples/bench_dry_mixer.rs"
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
- name: Benchmark baseline and candidate on this runner
run: |
set -o pipefail
echo "baseline=${{ steps.baseline.outputs.sha }}" | tee dry-mixer-perf.txt
echo "candidate=${GITHUB_SHA}" | tee -a dry-mixer-perf.txt
CARGO_TARGET_DIR="$RUNNER_TEMP/target-baseline" cargo run --release --manifest-path "$RUNNER_TEMP/photocraft-baseline/Cargo.toml" -p photocraft-engine --example bench_dry_mixer | tee baseline-mixer.txt
CARGO_TARGET_DIR="$RUNNER_TEMP/target-candidate" cargo run --release -p photocraft-engine --example bench_dry_mixer | tee candidate-mixer.txt
cat baseline-mixer.txt candidate-mixer.txt >> dry-mixer-perf.txt
python3 - <<'PY'
import re
def fingerprints(path):
rows = open(path).readlines()
result = {}
for row in rows:
match = re.search(r'wet=\s*(\d+)% sample_all=\s*(\w+)\s+size=\s*(\d+).*blake3=(\w+)', row)
if match:
result[match.group(1, 2, 3)] = match.group(4)
return result
before, after = fingerprints('baseline-mixer.txt'), fingerprints('candidate-mixer.txt')
assert len(before) == 12 and before == after, 'baseline/candidate mixer output or state differs'
PY
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: dry-mixer-performance
path: dry-mixer-perf.txt
+2
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
@@ -41,3 +42,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+2 -2
View File
@@ -26,8 +26,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# 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/windows-arm64.yml@main
with:
app-name: photocraft
app-dir: PhotoCraft
+10 -9
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
@@ -49,7 +49,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
### Never crash (outranks feature work)
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`.
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.
- **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) 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.
@@ -62,7 +62,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
@@ -80,7 +80,7 @@ Priorities: important infrastructure first, then low-hanging parity, then the lo
dimension, where PhotoCraft is lacking; `docs/gaps.md` ranks the work. `docs/parity-checklist.md`
(menu wiring) is not a measure of behaviour. When your work moves a number or closes a gap,
update the parity doc and gap entry it belongs to, with the date, and bump their status line
and revision history (craftrules `standards/progress-docs.md`).
and revision history.
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
the performance scenarios that are over budget or not measurable yet, and the count of
settings that do nothing. Its numbers are measured; prefer them to estimates.
@@ -130,7 +130,6 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `docs/parity-checklist.md`: generated Photoshop menu coverage.
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
- `log/` (local, gitignored): the dev log.
@@ -150,11 +149,13 @@ If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown P
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+29 -2
View File
@@ -23,22 +23,29 @@ next to them.
| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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/hu.tsv` | Hungarian 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/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/ar.tsv` | Arabic 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) |
| `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn and 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/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and 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/da.tsv` | Danish UI translations | @pechnikov2026-star and 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/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and 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) |
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `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`, `slice-select.svg`, `font-similar.svg`, `font-variable.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/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.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`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.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/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | 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/font-similar.svg`, `assets/icons/font-variable.svg` | Font menu filter glyphs: two waves (Show Similar Fonts) and a T over a slider (Show Variable Fonts), drawn on the 24 px icon grid | 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/path-op-{new-layer,combine,subtract,intersect,exclude,merge}.svg` | Path Operations glyphs (Pen options bar): a single square for New Layer, and two overlapping squares showing the area each operation keeps (union, back minus front, overlap, everything but the overlap, the merged outline), drawn 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 |
@@ -90,8 +97,12 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
| `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 |
| `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 |
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
| `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
| `docs/images/multi-channel-editing/{curves,curves-choices,levels,drawing,mask-view-before,mask-view-after}.png` | Selected colour channel editing (synthetic gradient) | @hidoba | Original synthetic gradient rendered with the offscreen `snapshot` example; adjacent JSON scripts | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
| `docs/images/multi-channel-editing/great-wave.jpg`, `rgb.png`, `cmyk.png`, `lab.png`, `gallery-channel-preview-{before,after}.png` | *The Great Wave off Kanagawa*, c. 1830–1832, and PhotoCraft channel-selection captures | Katsushika Hokusai (artwork); PhotoCraft contributors (UI) | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg), Metropolitan Museum reproduction; adjacent JSON capture scripts | Public domain artwork (PD-Art), adjacent `LICENSE-PUBLIC-DOMAIN.txt`; UI/scripts MIT OR Apache-2.0 |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
@@ -110,7 +121,8 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
## Test data (not committed, not shipped)
`corpus/` is gitignored and no test fixtures are committed to the repository.
`corpus/` is gitignored and no test fixtures are committed to the repository, except the small
synthetic parser fixtures listed under *Synthetic parser test fixtures* below.
`cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs`
and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence
next to the files:
@@ -129,3 +141,18 @@ next to the files:
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
## Synthetic parser test fixtures
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `crates/psd/tests/fixtures/atn/*.atn` | Independently encoded ATN version-16 and version-12 parser fixtures | PhotoCraft contributors (version-12 files: @yoriswind) | Original synthetic test data; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 |
## Embedded-vector editor source
The external SVG watcher, engine/UI handoff and bounded SVG rendering in this change
are adapted from Skyfuller1303’s MIT OR Apache-2.0 implementation in
[PhotoCraft #3409](https://github.com/storytold/photocraft/pull/3409), commit
`897f20072762fdf0eb688adcdb8d8cda7b38ce66`. Don Omondi’s embedded AI/PDF/EPS
extension and current-main integration build on that source. Clipboard and unrelated
Shape Tools changes from that branch are not included.
Generated
+131 -58
View File
@@ -323,7 +323,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -650,7 +650,7 @@ dependencies = [
"bitflags 2.13.2",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"itertools 0.13.0",
"proc-macro2",
"quote",
"regex",
@@ -1350,17 +1350,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1383,17 +1383,28 @@ dependencies = [
[[package]]
name = "craft-jpeg"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-pcraft-format"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b8f7d93b00dfb8f172938f3d2d796ae62dcfe123#b8f7d93b00dfb8f172938f3d2d796ae62dcfe123"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree 0.21.1",
"tempfile",
@@ -1404,7 +1415,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1425,13 +1436,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -2096,7 +2108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3297,7 +3309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
dependencies = [
"io-lifetimes 3.0.1",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3343,15 +3355,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -3887,6 +3890,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "lz4_flex"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
[[package]]
name = "maybe-owned"
version = "0.3.4"
@@ -4139,6 +4148,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -4551,6 +4569,16 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -4774,7 +4802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4961,10 +4989,12 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"arboard",
"async-io",
"base64 0.22.1",
"blake3",
"chrono",
"clap",
"core-graphics2",
@@ -4993,9 +5023,12 @@ dependencies = [
"photocraft-raster",
"photocraft-tablet",
"photocraft-ui-egui",
"plist",
"resvg",
"rfd",
"ron",
"roxmltree 0.21.1",
"rustix 1.1.5",
"serde",
"serde_json",
"tempfile",
@@ -5011,7 +5044,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"crc32fast",
"flate2",
@@ -5021,8 +5054,9 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"criterion",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
@@ -5035,7 +5069,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"base64 0.23.1",
"cap-std",
@@ -5046,7 +5080,9 @@ dependencies = [
"photocraft-doc",
"photocraft-engine",
"photocraft-format",
"photocraft-geom",
"photocraft-io",
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
"rmcp",
@@ -5060,7 +5096,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"clap",
"clap_complete",
@@ -5079,7 +5115,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"moxcms",
"rayon",
@@ -5087,7 +5123,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"craft-jpeg",
"exr",
@@ -5096,6 +5132,7 @@ dependencies = [
"image",
"image-webp",
"jpeg-encoder",
"jxl-oxide",
"photocraft-heif",
"png 0.18.1",
"proptest",
@@ -5105,11 +5142,12 @@ dependencies = [
"tiff",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
"zune-jpegxl",
]
[[package]]
name = "photocraft-color"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -5117,7 +5155,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"criterion",
"photocraft-cms",
@@ -5133,7 +5171,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -5144,8 +5182,9 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"base64 0.22.1",
"blake3",
"chrono",
"craft-print",
@@ -5185,9 +5224,10 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"blake3",
"craft-pcraft-format",
"crc32fast",
"exacl",
"flate2",
@@ -5208,14 +5248,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"bytemuck",
"half",
@@ -5235,19 +5275,20 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"blake3",
"craft-docpath",
"crc32fast",
"flate2",
"imagesize",
"miniz_oxide 0.9.1",
"photocraft-affinity",
"photocraft-algo",
@@ -5277,31 +5318,32 @@ dependencies = [
[[package]]
name = "photocraft-ipp"
version = "0.17.0"
version = "0.20.1"
[[package]]
name = "photocraft-ops"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"photocraft-cms",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"rayon",
"rustc-hash 2.1.3",
"serde",
"serde_json",
]
[[package]]
name = "photocraft-plugins"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -5317,8 +5359,9 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"encoding_rs",
"flate2",
"photocraft-psd",
"proptest",
@@ -5328,18 +5371,22 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"crc32fast",
"lz4_flex",
"photocraft-color",
"photocraft-geom",
"proptest",
"rayon",
"serde",
"sysinfo",
"winsafe",
]
[[package]]
name = "photocraft-raw"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"flate2",
"image",
@@ -5354,7 +5401,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.5",
@@ -5366,7 +5413,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"memory-stats",
"serde_json",
@@ -5374,7 +5421,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"flate2",
"parley",
@@ -5389,7 +5436,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"base64 0.23.1",
"craft-print",
@@ -5434,7 +5481,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5445,7 +5492,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"eframe",
"egui",
@@ -5467,7 +5514,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"flate2",
"js-sys",
@@ -5899,7 +5946,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6434,7 +6481,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6503,7 +6550,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6637,6 +6684,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -7323,6 +7381,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "sysinfo"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
@@ -7381,10 +7453,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.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8653,7 +8725,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8906,6 +8978,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -9183,7 +9256,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"chrono",
"serde",
+10 -6
View File
@@ -4,13 +4,13 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.17.0"
version = "0.20.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
# internal
photocraft-geom = { path = "crates/geom" }
photocraft-color = { path = "crates/color" }
@@ -39,10 +39,12 @@ photocraft-tablet = { path = "crates/tablet" }
photocraft-ipp = { path = "crates/ipp" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -52,6 +54,7 @@ clap_mangen = { version = "0.3.3", features = ["env"] }
arc-swap = "1"
bytemuck = { version = "1", features = ["derive"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
encoding_rs = { version = "0.8", default-features = false, features = ["alloc"] }
image = { version = "0.25", default-features = false }
# Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg.
zune-core = "0.5"
@@ -67,6 +70,7 @@ 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"
rustc-hash = "2.1.3"
criterion = { version = "0.8", default-features = false }
getrandom = "0.3"
# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump).
+31
View File
@@ -23,6 +23,37 @@ https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDP
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
ship with the package as OFL-<family>.txt.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The egui-icons font subset is embedded from epaint_default_fonts by crates/ui-egui/build.rs
and registered in the native and web fallback stacks by crates/ui-egui/src/egui_fonts.rs.
It is a subset of emoji-icon-font by John Slegers; the same upstream MIT license applies.
+12 -1
View File
@@ -286,6 +286,8 @@ The Flatpak prints through the host's CUPS socket (File › Print talks IPP to i
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.
To use the bundled CLI, run `./photocraft-<version>-linux-x86_64.AppImage --cli --help`, or `--cli mcp` to start the MCP server on stdio. CLI launches skip desktop integration. A symlink named `photocraft-cli` also selects the CLI; see [`docs/development.md`](docs/development.md#headless-cli).
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
@@ -394,13 +396,22 @@ stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | **Image editing: layers, masks, type and real PSD files · you are here** | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+4 -2
View File
@@ -122,8 +122,10 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
| 2026-11-02 | Load Actions: Mac Roman step names with é ä ö ü Ä or ° read as Mac Roman instead of windows-1252 or Cyrillic (#10774): windows-1252's rare 0x80–0x9F letters and a lone Cyrillic letter in a Latin word no longer count as common text, the degree sign does, and the file's own Unicode text settles a word-initial rare letter and breaks exact ties toward windows-1252 or Mac Roman |
| 2026-10-21 | Linux can use font-free Adwaita controls when System Title Bar is selected, while the integrated CSD title bar remains the default (as in #4439). Saved preferences are respected; startup and preference-dialog regressions cover both choices (#6266). |
| 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-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized (`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 |
| 2026-10-07 | 0.3.0. 10 UI languages; shortcut audit 214 → 0 failures; TIFF decode 10× faster |
@@ -143,4 +145,4 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation |
| 2026-10-10 | major | First version, per craftrules `standards/progress-docs.md` |
| 2026-10-10 | major | First version |
+2 -2
View File
@@ -83,11 +83,11 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// its version word, 12 or 16) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), ActionsError> {
let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?;
let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || matches!(bytes.get(..4), Some([0, 0, 0, 12 | 16]));
if !is_atn {
let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?;
let (steps, set) = load_actions(&text, name)?;
+50
View File
@@ -55,6 +55,14 @@ pub(crate) struct ExportArgs {
/// Compress TIFF output: none (the default), lzw or zip
#[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)]
pub(crate) tiff_compression: Option<TiffCompression>,
/// TIFF Save Transparency: on keeps the transparency (ahead of any alpha or spot channels),
/// off composites the image over white. Without it: kept unless the document has alpha or
/// spot channels
#[arg(long, value_name = "on|off", value_parser = on_off)]
pub(crate) tiff_transparency: Option<bool>,
/// Also write a TIFF image pyramid: half-size copies after the full image
#[arg(long)]
pub(crate) tiff_pyramid: bool,
/// A TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its
/// alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24
#[arg(long, value_name = "24|32", value_parser = tga_bits)]
@@ -74,6 +82,10 @@ pub(crate) struct ExportArgs {
/// and CMYK shrink) or progressive
#[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)]
pub(crate) jpeg_format: Option<JpegFormat>,
/// Request the flattened document at its canvas size in .pcraft output as
/// composite/full.png; it may be omitted. Check the manifest's full_composite member.
#[arg(long)]
pub(crate) pcraft_full_composite: bool,
}
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
@@ -91,6 +103,9 @@ pub(crate) struct ConvertArgs {
/// Allow an <OUT> that is the <IN> file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
#[arg(long)]
pub(crate) approve_high_memory: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
}
@@ -102,6 +117,9 @@ pub(crate) struct InfoArgs {
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
#[arg(long)]
pub(crate) approve_high_memory: bool,
}
/// Open a file (or start a new document), run engine commands in order, save the result
@@ -130,6 +148,9 @@ pub(crate) struct RunArgs {
/// Allow an --out that is the <FILE>, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
#[arg(long)]
pub(crate) approve_high_memory: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
/// `--cmd` and `--params` values in command-line order, set by [`parse`].
@@ -169,6 +190,25 @@ pub(crate) struct BatchArgs {
/// originals; --in-place allows it
#[arg(long)]
pub(crate) in_place: bool,
/// Include images in subfolders; every result goes straight into --out, so a later file of
/// the same name replaces an earlier one
#[arg(long)]
pub(crate) recursive: bool,
/// Name each result with Batch tokens such as {name}, {serial}, {date}, and {ext}
#[arg(long, value_name = "TEMPLATE")]
pub(crate) naming: Option<String>,
/// First serial number used by --naming
#[arg(long, value_name = "1-4294967295", value_parser = clap::value_parser!(u32).range(1..))]
pub(crate) start_serial: Option<u32>,
/// Stop after the first failed input
#[arg(long)]
pub(crate) stop_on_error: bool,
/// Write failed inputs and reasons to this text file
#[arg(long, value_name = "FILE")]
pub(crate) error_log: Option<PathBuf>,
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
#[arg(long)]
pub(crate) approve_high_memory: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
}
@@ -252,6 +292,8 @@ enum ValueError {
TiffCompression,
#[error("expected baseline, optimized or progressive")]
JpegFormat,
#[error("expected on or off")]
OnOff,
/// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused.
#[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)")]
Bridge,
@@ -306,6 +348,14 @@ fn tiff_compression(s: &str) -> Result<TiffCompression, ValueError> {
}
}
fn on_off(s: &str) -> Result<bool, ValueError> {
match s {
"on" => Ok(true),
"off" => Ok(false),
_ => Err(ValueError::OnOff),
}
}
fn jpeg_format(s: &str) -> Result<JpegFormat, ValueError> {
JpegFormat::parse(s).ok_or(ValueError::JpegFormat)
}
+78 -19
View File
@@ -58,32 +58,77 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
if !a.in_place && same_dir(in_dir, out_dir) {
return Err(CliError::OutIsIn(out_dir.to_path_buf()));
}
std::fs::create_dir_all(out_dir)?;
let mut inputs: Vec<PathBuf> = std::fs::read_dir(in_dir)
.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?
.flatten()
.map(|e| e.path())
.filter(|p| is_input(p))
.collect();
inputs.sort();
let inputs: Vec<photocraft_engine::file_cmds::BatchInput> = if a.recursive {
photocraft_engine::file_cmds::list_images_tree(&in_dir.to_string_lossy(), &out_dir.to_string_lossy(), "photocraft-cli batch")?
.into_iter()
.filter(|input| is_input(Path::new(&input.path)))
.collect()
} else {
let mut images = Vec::new();
let entries = std::fs::read_dir(in_dir).map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?;
for entry in entries {
let path = entry.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?.path();
if is_input(&path) {
images.push(photocraft_engine::file_cmds::BatchInput { path: path.to_string_lossy().into_owned(), rel_dir: String::new() });
}
}
// The tree listing already holds the engine's order (each folder's files, then its
// subfolders), which `{serial}` numbers; only the flat listing needs sorting.
images.sort_by(|a, b| a.path.cmp(&b.path));
images
};
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.export.format.as_deref().map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let named = photocraft_engine::file_cmds::plan_batch_names(
&inputs,
&out_dir.to_string_lossy(),
format.unwrap_or("same"),
&json!({"naming": a.naming.as_deref().unwrap_or(""), "startingSerial": a.start_serial.unwrap_or(1)}),
)
.map_err(|error| CliError::Naming(error.to_string()))?;
let targets: Vec<PathBuf> = match named {
Some(paths) => paths.into_iter().map(PathBuf::from).collect(),
None => inputs
.iter()
.map(|input| {
let path = Path::new(&input.path);
let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.path), str::to_owned);
let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
out_dir.join(format!("{stem}.{ext}"))
})
.collect(),
};
let plan: Vec<_> = inputs.iter().zip(&targets).map(|(input, target)| (input.path.clone(), target.to_string_lossy().into_owned())).collect();
photocraft_engine::file_cmds::validate_flat_outputs(&plan).map_err(|error| CliError::Naming(error.to_string()))?;
if let Some(log) = &a.error_log {
let same = |path: &Path| photocraft_engine::file_cmds::same_destination(log, path);
if inputs.iter().any(|input| same(Path::new(&input.path))) || targets.iter().any(|target| same(target)) {
return Err(CliError::BatchOption("--error-log must not replace an input or batch output".into()));
}
}
std::fs::create_dir_all(out_dir)?;
let mut failures = Vec::new();
for (input, target) in inputs.iter().zip(targets) {
let input = Path::new(&input.path);
// The selected format encodes the bytes, whatever extension `--naming` gives the file.
// Without --format, a file PhotoCraft only opens (.kra, raw, …) is saved as PSD, the
// same format `--naming`'s {ext} plans.
let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.to_string_lossy()), str::to_owned);
let r = (|| -> Result<Vec<String>> {
written.check(&target_text).map_err(CliError::OutputClaimed)?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
h.open(input)?;
h.open_with_approval(input, a.approve_high_memory)?;
for (id, p) in &actions {
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
if photocraft_engine::actions_cmds::shell_view_command(id)
|| photocraft_engine::actions_cmds::shell_save_command(id)
|| photocraft_engine::actions_cmds::noop_step(&h.session, id, p)
{
continue;
}
let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?;
@@ -98,17 +143,31 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
Err(e) => {
failed += 1;
let _ = writeln!(err, "FAIL {}: {e}", input.display());
failures.push((input.to_string_lossy().into_owned(), e.to_string()));
if a.stop_on_error {
break;
}
}
}
}
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
let skipped = inputs.len().saturating_sub(ok + failed);
if skipped > 0 {
let _ = writeln!(err, "--stop-on-error: {skipped} remaining input(s) skipped");
let _ = writeln!(out, "{ok} succeeded, {failed} failed, {skipped} skipped");
} else {
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
}
// After the summary, so a log that can't be written doesn't hide the counts.
if let Some(path) = &a.error_log {
let bytes = photocraft_engine::file_cmds::encode_batch_error_log(failures.iter().map(|(file, error)| (file.as_str(), error.as_str())));
photocraft_format::atomic_write(path, &bytes)?;
}
if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) }
}
+9 -3
View File
@@ -30,7 +30,8 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write
let (input, output) = (&a.input, &a.output);
let opts = ExportOptions::from(&a.export);
refuse_overwriting_input(a.in_place, input, output)?;
let mut o = files::open(input)?;
let import_opts = photocraft_io::ImportOptions { psd_high_memory_approved: a.approve_high_memory, ..Default::default() };
let mut o = files::open_with_options(input, &import_opts)?;
// 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);
@@ -43,7 +44,7 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write
pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result {
let file = &a.file;
let mut h = Headless::trusted_local();
let opened = h.open(file)?;
let opened = h.open_with_approval(file, a.approve_high_memory)?;
let mut doc = h.inspect(None)?;
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
@@ -86,13 +87,18 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) ->
if let Some(out) = &a.out {
refuse_overwriting_input(a.in_place, file, out)?;
}
let o = h.open(file)?;
let o = h.open_with_approval(file, a.approve_high_memory)?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
}
let cmds = command_list(a)?;
for (id, params) in cmds {
// A step that is a silent no-op in action playback (Deselect with nothing selected, an Arrange that cannot move the layer) is one in a script too (#8802).
if photocraft_engine::actions_cmds::noop_step(&h.session, &id, &params) {
print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?;
continue;
}
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(CliError::NestedFailure { id, message });
+3 -2
View File
@@ -32,9 +32,10 @@ pub(crate) enum CliError {
OutIsInput(PathBuf),
#[error("--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", .0.display())]
OutIsIn(PathBuf),
/// Another input already wrote this output ([`photocraft_engine::file_cmds::OutputClaims::check`]).
#[error("--naming: {0}")]
Naming(String),
#[error("{0}")]
OutputClaimed(String),
BatchOption(String),
#[error("{0} file(s) failed")]
FilesFailed(usize),
}
+2
View File
@@ -1,6 +1,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
// May re-execute this process with glibc tuned to return freed document memory (#8743).
photocraft_engine::malloc_tuning::apply();
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap,
// not panic before anything is printed (issue #1108).
let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
+8 -1
View File
@@ -8,7 +8,13 @@ use crate::{Result, args::ExportArgs};
impl From<&ExportArgs> for ExportOptions {
fn from(a: &ExportArgs) -> Self {
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, ..Default::default() };
let mut o = ExportOptions {
tiff_layers: a.tiff_layers,
tiff_transparency: a.tiff_transparency,
tga_bits: a.tga_bits,
pcraft_full_composite: a.pcraft_full_composite,
..Default::default()
};
if let Some(q) = a.quality {
o.encode.jpeg_quality = q;
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
@@ -19,6 +25,7 @@ impl From<&ExportArgs> for ExportOptions {
if let Some(c) = a.tiff_compression {
o.encode.tiff_compression = c;
}
o.encode.tiff_pyramid = a.tiff_pyramid;
if let Some(f) = a.jpeg_format {
o.encode.jpeg_format = f;
}
+312 -12
View File
@@ -434,9 +434,25 @@ fn batch_applies_actions_to_directory() {
write_png(&inp.join("two.png"), 9, 9, 2);
std::fs::write(inp.join("notes.txt"), "ignored").unwrap();
let actions = d.join("actions.json");
let log = d.join("errors.txt");
std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])).stdout;
let out = ok(bin()
.args(["batch", "--actions"])
.arg(&actions)
.arg("--in")
.arg(&inp)
.arg("--out")
.arg(&outp)
.args(["--format", "pcraft", "--error-log"])
.arg(&log))
.stdout;
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
let clean_log = std::fs::read(&log).unwrap();
assert!(clean_log.starts_with(&[0xff, 0xfe]));
let (pairs, remainder) = clean_log[2..].as_chunks::<2>();
assert!(remainder.is_empty());
let body = String::from_utf16(&pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect::<Vec<_>>()).unwrap();
assert_eq!(body, "Batch Start\r\n\r\nBatch End\r\n");
for n in ["one", "two"] {
let info = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
@@ -450,6 +466,127 @@ fn batch_applies_actions_to_directory() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn batch_recursive_naming_stop_and_error_log() {
let root = tmp("batch-recursive-options");
let input = root.join("in");
std::fs::create_dir_all(input.join("sub")).unwrap();
for file in ["a.png", "sub/a.png", "sub/c.png"] {
write_png(&input.join(file), 8, 6, 3);
}
std::fs::write(input.join("sub/b.png"), b"not a png").unwrap();
let actions = root.join("actions.json");
std::fs::write(&actions, r#"[["image.imageRotation.90cw", {}]]"#).unwrap();
let output = root.join("out");
let log = root.join("errors.txt");
let run = |out: &Path, extra: &[&str]| {
bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap()
};
let result = run(&output, &["--recursive", "--naming", "{name}-{serial:2}.{ext}", "--start-serial", "5", "--error-log", &log.to_string_lossy()]);
let stdout = String::from_utf8_lossy(&result.stdout);
assert!(!result.status.success(), "{stdout}");
assert!(stdout.contains("3 succeeded, 1 failed"), "{stdout}");
for file in ["a-05.png", "a-06.png", "c-08.png"] {
assert!(output.join(file).is_file(), "{file}: {stdout}");
}
let bytes = std::fs::read(log).unwrap();
assert!(bytes.starts_with(&[0xff, 0xfe]));
let (pairs, remainder) = bytes[2..].as_chunks::<2>();
assert!(remainder.is_empty());
let errors = String::from_utf16(&pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect::<Vec<_>>()).unwrap();
assert!(errors.contains("Batch Start\r\n") && errors.contains("b.png") && errors.ends_with("Batch End\r\n"), "{errors}");
let stopped = root.join("stopped");
let result = run(&stopped, &["--recursive", "--stop-on-error"]);
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(!result.status.success(), "{stdout}");
assert!(stdout.contains("2 succeeded, 1 failed, 1 skipped"), "{stdout}");
assert!(stderr.contains("--stop-on-error"), "{stderr}");
assert!(!stopped.join("c.png").exists());
let collision = root.join("collision");
let alias = collision.join("../collision/a.png");
let result = run(&collision, &["--recursive", "--error-log", &alias.to_string_lossy()]);
assert!(!result.status.success());
assert!(String::from_utf8_lossy(&result.stderr).contains("--error-log"));
assert!(!collision.join("a.png").exists());
}
/// `{serial}` numbers a recursive batch in the engine's order (a folder's files before its
/// subfolders), `--format` encodes the bytes whatever extension `--naming` writes, and an
/// `--error-log` that can't be written still leaves the summary printed.
#[test]
fn batch_naming_keeps_the_tree_order_and_the_selected_encoder() {
let root = tmp("batch-naming-order");
let input = root.join("in");
std::fs::create_dir_all(input.join("sub")).unwrap();
write_png(&input.join("z.png"), 8, 6, 3);
write_png(&input.join("sub/a.png"), 5, 4, 3);
let actions = root.join("actions.json");
std::fs::write(&actions, "[]").unwrap();
let run = |out: &Path, extra: &[&str]| {
bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap()
};
let png_width = |path: &Path| {
let bytes = std::fs::read(path).unwrap();
u32::from_be_bytes(bytes[16..20].try_into().unwrap())
};
let ordered = root.join("ordered");
let result = run(&ordered, &["--recursive", "--naming", "{serial:2}.{ext}"]);
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
assert_eq!(png_width(&ordered.join("01.png")), 8, "the root file comes first");
assert_eq!(png_width(&ordered.join("02.png")), 5);
let jpeg = root.join("jpeg");
let result = run(&jpeg, &["--format", "jpg", "--naming", "{name}_{serial}"]);
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
assert!(std::fs::read(jpeg.join("z_001")).unwrap().starts_with(&[0xff, 0xd8]), "JPEG bytes without an extension");
let png = root.join("png");
let result = run(&png, &["--format", "png", "--naming", "{name}.jpg"]);
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
assert!(std::fs::read(png.join("z.jpg")).unwrap().starts_with(b"\x89PNG"), "PNG bytes under a .jpg name");
let log = root.join("log-is-a-folder");
std::fs::create_dir_all(&log).unwrap();
let result = run(&root.join("logged"), &["--error-log", &log.to_string_lossy()]);
let stdout = String::from_utf8_lossy(&result.stdout);
assert!(!result.status.success(), "{stdout}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
std::fs::remove_dir_all(root).unwrap();
}
/// Without --format, a file PhotoCraft only opens is saved as PSD, with or without --naming:
/// the encoder gets the format the planned name uses, not the input's import-only extension.
#[test]
fn batch_saves_import_only_inputs_as_psd() {
let root = tmp("batch-import-only");
let input = root.join("in");
std::fs::create_dir_all(&input).unwrap();
std::fs::write(
input.join("logo.xpm"),
"/* XPM */
static char *logo[] = {
\"2 2 1 1\",
\"a c #FF0000\",
\"aa\",
\"aa\"
};
",
)
.unwrap();
let actions = root.join("actions.json");
std::fs::write(&actions, "[]").unwrap();
for (name, extra) in [("plain", &[][..]), ("named", &["--naming", "{name}.{ext}"][..])] {
let out = root.join(name);
let result = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out).args(extra).output().unwrap();
assert!(result.status.success(), "{name}: {}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));
assert!(std::fs::read(out.join("logo.psd")).unwrap().starts_with(b"8BPS"), "{name}: PSD bytes");
assert!(!out.join("logo.xpm").exists(), "{name}");
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn batch_skips_recorded_view_and_save_steps() {
let d = tmp("batch-shell-steps");
@@ -804,6 +941,33 @@ 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");
}
#[test]
fn batch_naming_rejects_unread_input_alias_through_a_missing_directory() {
let d = tmp("batch-missing-directory-alias");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
write_png(&input.join("b.png"), 8, 4, 7);
let original = std::fs::read(input.join("b.png")).unwrap();
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let output = bin()
.arg("batch")
.arg("--actions")
.arg(&actions)
.arg("--in")
.arg(&input)
.arg("--out")
.arg(input.join("missing/.."))
.args(["--in-place", "--naming", "b.{ext}"])
.output()
.unwrap();
assert!(!output.status.success(), "{}", String::from_utf8_lossy(&output.stdout));
assert!(String::from_utf8_lossy(&output.stderr).contains("replace an input"));
assert_eq!(std::fs::read(input.join("b.png")).unwrap(), original);
assert!(!input.join("missing").exists());
}
/// #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]
@@ -944,9 +1108,9 @@ fn mcp_bridge_takes_a_port_or_an_address() {
let _ = child.wait();
}
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
/// Photoshop Batch processes every input in order, replacing earlier flat results with the same name.
#[test]
fn batch_fails_inputs_that_share_an_output_name() {
fn batch_later_input_replaces_a_same_named_output() {
let d = tmp("batch-collide");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
@@ -961,15 +1125,10 @@ fn batch_fails_inputs_that_share_an_output_name() {
let out_dir = d.join("out");
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert!(
!o.status.success(),
"a lost result must fail the run
{out}
{err}"
);
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
assert_eq!(err.matches("not written").count(), 2, "{err}");
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
assert!(o.status.success(), "{out}{err}");
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
assert!(out_dir.join("a.png").is_file());
assert!(std::fs::read_dir(&out_dir).unwrap().count() <= 2);
// Keeping each file's format, every input gets its own output.
let out_dir = d.join("same");
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)).stdout;
@@ -1025,6 +1184,26 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn pcraft_output_has_no_full_composite_unless_asked() {
let d = tmp("pcraft-full-composite");
let full = |path: &std::path::Path| photocraft_format::read_full_composite(&std::fs::read(path).unwrap()).unwrap().is_some();
for (name, flag, expect) in [("plain.pcraft", None, false), ("full.pcraft", Some("--pcraft-full-composite"), true)] {
let out = d.join(name);
let mut cmd = bin();
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]);
if let Some(flag) = flag {
cmd.arg(flag);
}
ok(cmd.arg("--out").arg(&out));
assert_eq!(full(&out), expect, "{name}");
}
let converted = d.join("converted.pcraft");
ok(bin().arg("convert").arg(d.join("plain.pcraft")).arg(&converted).arg("--pcraft-full-composite"));
assert!(full(&converted));
std::fs::remove_dir_all(d).unwrap();
}
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
/// `std::env::args()` before anything was printed. clap reads the arguments as they are: a path
/// takes one, and text (`--filter`) refuses it with a usage error.
@@ -1090,6 +1269,20 @@ fn batch_plays_a_photoshop_actions_file() {
assert!(!o.status.success());
}
/// A version 12 Actions file (Photoshop 5, often saved on classic Mac OS without an extension) is
/// recognised by its version word like version 16, not read as a JSON actions list.
#[test]
fn extensionless_version_12_actions_file_is_read_as_atn() {
let d = tmp("atn-v12");
let path = d.join("Legacy Actions");
std::fs::write(&path, include_bytes!("../../../../crates/psd/tests/fixtures/atn/v12-commands.atn")).unwrap();
let (steps, set, warnings) = photocraft_cli::load_actions_file(&path, None).unwrap();
assert_eq!(set.len(), 1);
assert_eq!(steps, vec![("actions.play".to_string(), json!({"action": set[0].name}))]);
assert!(warnings.is_empty(), "{warnings:?}");
std::fs::remove_dir_all(d).unwrap();
}
/// What `photocraft_cli::run` returned and wrote, in-process.
struct Invoke {
code: i32,
@@ -1421,3 +1614,110 @@ fn control_token_env_vars_are_the_flags_fallbacks() {
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", ""));
assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}");
}
/// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does (#6744),
/// so a script that deselects after Reveal Selection still finishes and saves.
#[test]
fn run_skips_deselect_with_nothing_selected() {
let folder = tmp("deselect-run");
let native = folder.join("out.pcraft").to_string_lossy().to_string();
let Invoke { code, stdout, stderr } = invoke(&[
"run",
"--new",
r#"{"width":16,"height":16}"#,
"--cmd",
"select.rect",
"--params",
r#"{"x":0,"y":0,"width":8,"height":16}"#,
"--cmd",
"layer.new.layer",
"--cmd",
"layer.layerMask.revealSelection",
"--cmd",
"select.deselect",
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"after"}"#,
"--out",
&native,
]);
assert_eq!(code, 0, "{stderr}");
let results: Vec<Value> = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(results.len(), 5, "every command reports a result: {stdout}");
assert_eq!(results[3], json!({"command": "select.deselect", "result": null}));
let Invoke { code, stdout, stderr } = invoke(&["info", &native, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}");
// Deselect with a selection still deselects: Inverse afterwards finds nothing to invert.
let Invoke { code, stderr, .. } = invoke(&[
"run",
"--new",
r#"{"width":16,"height":16}"#,
"--cmd",
"select.all",
"--cmd",
"select.deselect",
"--cmd",
"select.inverse",
"--out",
&native,
]);
assert_eq!(code, 1, "{stderr}");
assert!(stderr.contains("select.inverse") && stderr.contains("no selection"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
/// #8802: a batched action that starts with Deselect plays on files with nothing selected, as the
/// same action does in the Actions panel (#6744).
#[test]
fn batch_skips_deselect_with_nothing_selected() {
let dir = tmp("deselect-batch");
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap();
let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]);
assert_eq!(code, 0, "{stdout}{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}");
let _ = std::fs::remove_dir_all(&dir);
}
/// TIFF Options from the command line (#8827): `--tiff-compression`, `--tiff-transparency on|off`
/// and `--tiff-pyramid`; a bad value is a usage error that writes nothing.
#[test]
fn tiff_options_choose_compression_transparency_and_pyramid() {
let d = tmp("tiff-options");
let a = d.join("a.png");
write_png(&a, 200, 100, 3);
// (flags, compression tag, alpha, directories)
for (flags, compression, alpha, dirs) in [
(&[][..], 1, true, 1),
(&["--tiff-compression", "lzw"][..], 5, true, 1),
(&["--tiff-transparency", "off", "--tiff-compression", "zip"][..], 8, false, 1),
(&["--tiff-transparency", "on", "--tiff-pyramid"][..], 1, true, 3),
] {
let out = d.join("a.tif");
ok(bin().arg("convert").arg(&a).arg(&out).args(flags));
let bytes = std::fs::read(&out).unwrap();
let info = photocraft_codecs::tiff_info(&bytes).unwrap();
assert_eq!(info.pages.len(), dirs, "{flags:?}");
assert!(info.pages.iter().all(|p| p.compression == compression), "{flags:?}: {:?}", info.pages);
assert_eq!(photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha(), alpha, "{flags:?}");
}
for (flag, bad, expected) in [("--tiff-transparency", "maybe", "expected on or off"), ("--tiff-compression", "jpeg", "expected none, lzw or zip")] {
let out = d.join("bad.tif");
let o = bin().arg("convert").arg(&a).arg(&out).args([flag, bad]).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{flag} {bad}");
assert!(err.contains(flag) && err.contains(expected), "{flag} {bad}: {err}");
assert!(!out.exists(), "{flag} {bad} wrote a file");
}
}
+2
View File
@@ -1,6 +1,8 @@
//! The crate's integration tests, linked into one test binary.
mod cli;
#[cfg(all(target_os = "linux", target_env = "gnu"))]
mod malloc_tuning;
mod man_and_completions;
mod orientation;
@@ -0,0 +1,41 @@
//! The binary re-executes itself with glibc's trim threshold set (#8743), keeping its arguments
//! and a value the user set.
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use photocraft_engine::malloc_tuning::{TRIM_THRESHOLD, TRIM_THRESHOLD_VAR};
/// The environment of a running `serve` once it answered a request, as `NAME=value` entries.
fn serve_environ(set: Option<&str>) -> Vec<String> {
let mut cmd = Command::new(crate::binary());
for var in [TRIM_THRESHOLD_VAR, "MALLOC_MMAP_THRESHOLD_", "MALLOC_TOP_PAD_", "MALLOC_MMAP_MAX_", "MALLOC_ARENA_MAX", "GLIBC_TUNABLES"] {
cmd.env_remove(var);
}
if let Some(v) = set {
cmd.env(TRIM_THRESHOLD_VAR, v);
}
let mut child = cmd.arg("serve").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
writeln!(stdin, r#"{{"id":1,"method":"session.list"}}"#).unwrap();
let mut reply = String::new();
BufReader::new(child.stdout.take().unwrap()).read_line(&mut reply).unwrap();
assert!(reply.contains(r#""id":1"#), "serve answered after the re-exec: {reply}");
// The same process: `exec` keeps the PID, so this is the re-executed program's environment.
let environ = std::fs::read(format!("/proc/{}/environ", child.id())).unwrap();
drop(stdin);
child.wait().unwrap();
environ.split(|&b| b == 0).map(|e| String::from_utf8_lossy(e).into_owned()).collect()
}
#[test]
fn sets_the_trim_threshold() {
let env = serve_environ(None);
assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}={TRIM_THRESHOLD}")), "{env:?}");
}
#[test]
fn keeps_a_value_the_user_set() {
let env = serve_environ(Some("999999"));
assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}=999999")), "{env:?}");
}
+14 -3
View File
@@ -103,7 +103,7 @@ pub fn start() {
}
let unsaved = Arc::new(AtomicBool::new(false));
guard_unload(unsaved.clone());
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets, ui_language: "" }))
}),
)
.await;
@@ -224,7 +224,10 @@ fn load_font_manifest(served: Served, ctx: egui::Context) {
served_fonts::add_fonts(&fonts);
// Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets
// the Arabic font before typing); the rest is fetched when text or a font menu needs it.
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str));
// Also the interface language's: `serve_fonts` asked for it once already, maybe before
// this manifest arrived, when no fallback was registered yet.
let ui_language = photocraft_ui_egui::i18n::current().code();
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str).chain([ui_language]));
ctx.request_repaint();
});
}
@@ -270,6 +273,8 @@ struct WebShell {
served: Served,
database: Option<web_sys::IdbDatabase>,
presets: crate::preset_bridge::Bridge,
/// The interface language whose served script font was last asked for.
ui_language: &'static str,
}
impl WebShell {
@@ -294,6 +299,12 @@ impl WebShell {
/// Fetches the served families asked for (picked in a font menu, or needed by a layout) and
/// installs the files that arrived.
fn serve_fonts(&mut self, ctx: &egui::Context) {
// The interface language's script font draws its labels (Arabic, Japanese…).
let ui_language = photocraft_ui_egui::i18n::current().code();
if ui_language != self.ui_language {
self.ui_language = ui_language;
served_fonts::request_for_languages([ui_language]);
}
let requested = served_fonts::take_requests();
let (fetch, arrived) = {
let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner);
@@ -320,7 +331,7 @@ impl WebShell {
}
});
}
served_fonts::install(&mut self.app, arrived);
served_fonts::install(&mut self.app, ctx, arrived);
}
}
+17 -1
View File
@@ -34,11 +34,14 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
chrono = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "=3.27.0"
roxmltree = "0.21"
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { version = "3.7", features = ["wayland-data-control"] }
@@ -69,6 +72,15 @@ muda = { version = "=0.21.2", default-features = false }
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "=1.1.5", features = ["fs"] }
# Reads CFBundleExecutable from a chosen macOS application bundle's Info.plist (XML or binary) for
# external vector editing. Already in Cargo.lock (craft-update); built on every Unix so Linux CI
# compiles and tests the bundle checks. The event-stream reader (behind plist's "unstable"
# feature) lets us cap nesting: building a `Value`, or serde's ignored fields, recurses per level.
# Pinned exactly because that API may change in a minor release: review the diff before bumping.
plist = { version = "=1.10.3", features = ["enable_unstable_features_that_may_break_with_minor_version_bumps"] }
# 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 }
@@ -88,7 +100,7 @@ futures-lite = "=2.6.1"
async-io = "=2.6.0"
# Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't
# re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly.
winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
winit = { version = "=0.30.13", default-features = false, features = ["wayland-csd-adwaita-notitle", "x11"] }
# Safe Win32 wrappers on Windows: the pointer position for file drops (`cursor.rs`,
# GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles),
@@ -110,3 +122,7 @@ harness = false
[build-dependencies]
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
[dev-dependencies]
# Opt-in native save-back acceptance transports embedded sources through engine JSON.
base64 = "=0.22.1"
+23 -6
View File
@@ -390,7 +390,7 @@ fn stage_copy(from: &Path, to: &Path) -> std::io::Result<PathBuf> {
fn remove_stale_staging(folder: &Path) {
let Ok(entries) = std::fs::read_dir(folder) else { return };
for entry in entries.flatten() {
if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
if entry.file_name().to_str().is_none_or(|n| side_of(n, STAGED).is_none()) {
continue;
}
let removed = match entry.file_type() {
@@ -491,14 +491,31 @@ pub fn portable_marker(exe_dir: &Path) -> Option<PathBuf> {
PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file())
}
/// Probe names tried before giving up on a portable data folder.
const WRITE_PROBE_ATTEMPTS: u32 = 64;
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
///
/// The probe is created exclusively (`create_new`), so an entry already at its name (a link
/// someone else placed there, say) is never opened, written through or removed: the next name
/// is tried instead. Only a probe this call created is removed.
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
// A leftover probe is harmless; don't fail portable mode over it.
let _ = std::fs::remove_file(&probe);
Ok(())
let pid = std::process::id();
for attempt in 0..WRITE_PROBE_ATTEMPTS {
let probe = dir.join(format!(".write-test-{pid}-{attempt}"));
match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) {
Ok(file) => {
drop(file);
// A leftover probe is harmless; don't fail portable mode over it.
let _ = std::fs::remove_file(&probe);
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every write-test name is already taken"))
}
#[cfg(test)]
+25
View File
@@ -443,3 +443,28 @@ fn missing_exe_dir_or_env_never_panics() {
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None);
assert_eq!(d.dir, None, "empty variables are ignored");
}
#[cfg(unix)]
#[test]
fn the_write_probe_never_follows_an_entry_already_at_its_name() {
let root = temp("probe-link");
let data = root.join("data");
std::fs::create_dir_all(&data).unwrap();
let victim = root.join("victim.txt");
std::fs::write(&victim, b"keep me").unwrap();
// Every name the probe tries, except the last, is a link to a file outside the folder.
let pid = std::process::id();
for attempt in 0..WRITE_PROBE_ATTEMPTS - 1 {
std::os::unix::fs::symlink(&victim, data.join(format!(".write-test-{pid}-{attempt}"))).unwrap();
}
ensure_writable(&data).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"keep me");
// The links are left alone, and the probe that was created is gone.
assert!(std::fs::symlink_metadata(data.join(format!(".write-test-{pid}-0"))).unwrap().file_type().is_symlink());
assert!(!data.join(format!(".write-test-{pid}-{}", WRITE_PROBE_ATTEMPTS - 1)).exists());
// With every name taken the folder is reported unwritable, still without touching the file.
std::os::unix::fs::symlink(&victim, data.join(format!(".write-test-{pid}-{}", WRITE_PROBE_ATTEMPTS - 1))).unwrap();
assert!(ensure_writable(&data).is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"keep me");
let _ = std::fs::remove_dir_all(&root);
}
+11 -1
View File
@@ -15,7 +15,7 @@
//! be stamped on the shortcut, or the taskbar would stop matching the two.
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
/// on Apple's icon grid; elsewhere the 256 px hicolor render of the full tile.
pub fn window_icon() -> egui::IconData {
match eframe::icon_data::from_png_bytes(PNG) {
Ok(icon) => icon,
@@ -108,6 +108,16 @@ mod tests {
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
}
#[test]
fn windows_and_linux_icons_keep_the_round_corners() {
// rx=112 of 512 leaves (10, 10) of a 256 px render transparent; the old crop filled it (#4478).
let png = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
let icon = eframe::icon_data::from_png_bytes(png).unwrap();
assert_eq!(icon.width, 256);
assert_eq!(icon.rgba[(10 * 256 + 10) * 4 + 3], 0, "corner is transparent");
assert_eq!(icon.rgba[(128 * 256 + 2) * 4 + 3], 255, "the tile is full bleed");
}
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
#[test]
fn exe_icon_has_every_shell_size() {
+27 -8
View File
@@ -16,15 +16,10 @@ use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
use serde_json::{Value, json};
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
/// Start serving a listener already bound during startup, before a window is opened.
pub fn start(listener: TcpListener, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
let port = listener.local_addr().map_or(0, |a| a.port());
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
std::thread::spawn(move || {
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
@@ -152,6 +147,30 @@ mod tests {
TcpStream::connect(&logged).unwrap();
}
#[test]
fn a_prebound_listener_authenticates_and_dispatches_requests() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let requests = start(listener, TOKEN.into(), egui::Context::default());
let mut stream = TcpStream::connect(address).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut replies = BufReader::new(stream.try_clone().unwrap());
writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap();
let mut line = String::new();
replies.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
writeln!(stream, "{}", json!({"id": 2, "method": "test.bound"})).unwrap();
let request = requests.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(request.method, "test.bound");
request.reply.send(json!({"ok": true, "result": "served"})).unwrap();
line.clear();
replies.read_line(&mut line).unwrap();
let reply: Value = serde_json::from_str(&line).unwrap();
assert_eq!(reply["id"], 2);
assert_eq!(reply["result"], "served");
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
File diff suppressed because it is too large. Load diff
+193
View File
@@ -0,0 +1,193 @@
//! Launch a sandboxed editor through the desktop, exporting only the writable working SVG.
//! Never execute another Flatpak's payload inside PhotoCraft or grant access to its home.
use super::native::OwnedSvg;
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, TryRecvError};
use std::time::{Duration, Instant};
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::{Fd, OwnedObjectPath, OwnedValue, Value};
/// Ask the desktop (xdg-desktop-portal OpenURI) to let the user pick an editor for the working
/// file, on a fresh session-bus connection with a 5-second method timeout. Returns once the
/// portal answers; `stop` or a 120-second deadline cancels the wait.
pub(super) fn open(owned: &OwnedSvg, stop: &Receiver<()>) -> Result<(), String> {
let connection =
zbus::blocking::connection::Builder::session().map_err(|e| e.to_string())?.method_timeout(Duration::from_secs(5)).build().map_err(|e| e.to_string())?;
open_on(&connection, owned, stop)
}
/// [`open`] on an existing connection: share a checked writable handle (never a pathname) with
/// `OpenFile`, always asking the user to choose, and wait for the named request's response.
fn open_on(connection: &Connection, owned: &OwnedSvg, stop: &Receiver<()>) -> Result<(), String> {
use futures_lite::StreamExt;
// Retain the checked handle for the portal call. Writable document-portal access also
// supports atomic saves. The watcher keeps using PhotoCraft's original owned pathname.
let file = owned.share_handle()?;
let unique = connection.unique_name().ok_or("No desktop connection name")?.as_str().trim_start_matches(':').replace('.', "_");
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let token = format!("photocraft_vector_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
let request = Proxy::new(connection, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
// Subscribe before launching: the portal can finish before OpenFile returns.
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
let portal = Proxy::new(connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.OpenURI")
.map_err(|e| e.to_string())?;
// SVG's desktop default may be a viewer or browser. Always let the user select an editor.
let options = HashMap::from([("handle_token", Value::from(token.as_str())), ("writable", Value::from(true)), ("ask", Value::from(true))]);
let handle: OwnedObjectPath =
portal.call("OpenFile", &("", Fd::from(&file), options)).map_err(|e| format!("Cannot open the desktop editor chooser: {e}"))?;
if handle.as_str() != path {
let returned =
Proxy::new(connection, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
let _: Result<(), _> = returned.call("Close", &());
return Err("The desktop portal does not support named requests".into());
}
let deadline = Instant::now() + Duration::from_secs(120);
loop {
if !matches!(stop.try_recv(), Err(TryRecvError::Empty)) || Instant::now() >= deadline {
let _: Result<(), _> = request.call("Close", &());
return Err("Vector editor selection cancelled or timed out".into());
}
let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async {
async_io::Timer::after(Duration::from_millis(50)).await;
None
}));
if let Some(reply) = next {
let reply = reply.ok_or("The desktop editor chooser closed without an answer")?;
let (code, _): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
return match code {
0 => Ok(()),
1 => Err("Vector editor selection cancelled".into()),
_ => Err("The desktop could not open the vector editor".into()),
};
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, Read, Seek, Write};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
struct Bus(Child);
impl Drop for Bus {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn bus() -> (Bus, Connection, Connection) {
let guard = crate::CHILD_SPAWNS.read().unwrap();
let mut child = Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(Stdio::piped()).spawn().unwrap();
drop(guard);
let mut address = String::new();
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut address).unwrap();
let server = zbus::blocking::connection::Builder::address(address.trim()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
let client = zbus::blocking::connection::Builder::address(address.trim()).unwrap().method_timeout(Duration::from_secs(2)).build().unwrap();
(Bus(child), server, client)
}
/// A real D-Bus FD transfer carries only the owned SVG, with explicit writable/chooser
/// options. An immediate response cannot race subscription; writing that FD updates the
/// original watched path. A rejected chooser never succeeds or launches a fallback.
#[test]
fn shares_a_writable_working_copy_and_handles_chooser_responses() {
for code in [0u32, 1, 2] {
let (_bus, server, client) = bus();
let owned = OwnedSvg::create(b"<svg/>").unwrap();
let messages = zbus::blocking::MessageIterator::from(&server);
let worker = std::thread::spawn(move || {
for message in messages {
let message = message.unwrap();
let header = message.header();
if header.member().map(|m| m.as_str()) != Some("OpenFile") {
continue;
}
let (parent, fd, options): (String, zbus::zvariant::OwnedFd, HashMap<String, OwnedValue>) = message.body().deserialize().unwrap();
assert!(parent.is_empty());
assert!(bool::try_from(options["ask"].try_clone().unwrap()).unwrap());
assert!(bool::try_from(options["writable"].try_clone().unwrap()).unwrap());
let mut file = std::fs::File::from(std::os::fd::OwnedFd::from(fd));
let mut bytes = String::new();
file.read_to_string(&mut bytes).unwrap();
assert_eq!(bytes, "<svg/>");
if code == 0 {
file.rewind().unwrap();
file.write_all(b"<svg >").unwrap();
file.sync_all().unwrap();
}
let token = String::try_from(options["handle_token"].try_clone().unwrap()).unwrap();
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
server
.emit_signal(
header.sender().cloned().map(zbus::names::BusName::from),
path.as_str(),
"org.freedesktop.portal.Request",
"Response",
&(code, HashMap::<String, Value<'_>>::new()),
)
.unwrap();
// Keep the owner alive until the client has consumed its response.
return server;
}
panic!("OpenFile was not called");
});
let (_stop, stopped) = mpsc::channel();
assert_eq!(open_on(&client, &owned, &stopped).is_ok(), code == 0);
let _server = worker.join().unwrap();
assert_eq!(std::fs::read(&owned.file).unwrap(), if code == 0 { b"<svg >" } else { b"<svg/>" });
}
}
#[test]
fn refuses_redirected_or_nonregular_files_before_contacting_the_portal() {
let (_bus, _server, client) = bus();
let owned = OwnedSvg::create(b"<svg/>").unwrap();
let other = tempfile::NamedTempFile::new().unwrap();
std::fs::remove_file(&owned.file).unwrap();
std::os::unix::fs::symlink(other.path(), &owned.file).unwrap();
let (_stop, stopped) = mpsc::channel();
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("regular file"));
std::fs::remove_file(&owned.file).unwrap();
rustix::fs::mkfifoat(rustix::fs::CWD, &owned.file, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR).unwrap();
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("regular file"));
}
#[test]
fn stopping_sync_closes_an_unanswered_desktop_request() {
let (_bus, server, client) = bus();
let owned = OwnedSvg::create(b"<svg/>").unwrap();
let messages = zbus::blocking::MessageIterator::from(&server);
let (stop, stopped) = mpsc::channel();
let worker = std::thread::spawn(move || {
let mut pending_stop = Some(stop);
for message in messages {
let message = message.unwrap();
let header = message.header();
match header.member().map(|member| member.as_str()) {
Some("OpenFile") => {
let (_, _, options): (String, zbus::zvariant::OwnedFd, HashMap<String, OwnedValue>) = message.body().deserialize().unwrap();
let token = String::try_from(options["handle_token"].try_clone().unwrap()).unwrap();
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
pending_stop.take().unwrap().send(()).unwrap();
}
Some("Close") => {
server.reply(&header, &()).unwrap();
return;
}
_ => {}
}
}
panic!("The desktop request was not closed");
});
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("cancelled"));
worker.join().unwrap();
}
}
+9 -7
View File
@@ -535,18 +535,20 @@ impl Sentinel {
/// file, read before the window opens; `Auto` when the file or value is missing or unknown.
#[cfg(any(target_os = "linux", test))]
pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::LinuxDisplayServer {
read_performance(path).map(|performance| performance.linux_display_server).unwrap_or_default()
}
/// The preferences file's Performance section, read as the app reads it: a value of the wrong
/// type keeps its default without resetting the others. `None` without a file or section.
fn read_performance(path: Option<&Path>) -> Option<photocraft_engine::prefs::Performance> {
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| serde_json::from_value::<photocraft_engine::prefs::Performance>(p.clone()).ok())
.map(|performance| performance.linux_display_server)
.unwrap_or_default()
v.get("performance").cloned().map(photocraft_engine::prefs::read_section)
}
/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
/// opens; empty (automatic) when the file or value is missing.
/// opens; empty (automatic) when the file or value is missing or not text.
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()
read_performance(path).map(|performance| performance.gpu_adapter.chars().take(256).collect()).unwrap_or_default()
}
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
+4
View File
@@ -37,6 +37,8 @@ fn display_server_preference_is_read_before_the_window_opens() {
// Unknown values, other types, broken JSON and a missing file all start as Auto.
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"wayland"}}"#), LinuxDisplayServer::Auto);
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":11}}"#), LinuxDisplayServer::Auto);
// A bad value beside it resets only itself, as when the app loads the file (#6611).
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"x11","cacheLevels":"four"}}"#), LinuxDisplayServer::X11);
assert_eq!(read("{not json"), LinuxDisplayServer::Auto);
assert_eq!(read_display_server(Some(&dir.join("missing.json"))), LinuxDisplayServer::Auto);
assert_eq!(read_display_server(None), LinuxDisplayServer::Auto);
@@ -210,6 +212,8 @@ fn gpu_adapter_is_read_before_the_window_opens() {
};
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
// A bad value beside it resets only itself, as when the app loads the file (#6611).
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia","cacheLevels":"four"}}"#), "nvidia");
assert_eq!(read(r#"{"performance":{}}"#), "");
assert_eq!(read("not json"), "");
assert_eq!(read_gpu_adapter(None), "");
+25 -1
View File
@@ -315,22 +315,31 @@ pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
/// The file [`AppLogger::attach_dir`] opened, so a failure can tell the user what to attach.
path: Mutex<Option<PathBuf>>,
/// 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)), owner: Mutex::new(None) }
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), path: Mutex::new(None), 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 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.path.lock().unwrap_or_else(PoisonError::into_inner) = Some(path.clone());
*self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner;
Ok(path)
}
/// The log file records are written to, once [`AppLogger::attach_dir`] opened one: `None` when
/// the file could not be created (standard error is the only sink, see [`install`]).
pub fn path(&self) -> Option<PathBuf> {
self.path.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
}
impl log::Log for AppLogger {
@@ -379,6 +388,21 @@ mod tests {
d
}
/// The startup-failure dialog names the log file, so the logger has to remember it (#7643).
/// A start that couldn't create one keeps `None` and the dialog leaves the line out.
#[test]
fn path_names_the_attached_log_file() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("path");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(logger.path(), None, "nothing is written before a file is attached");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(logger.path(), Some(path));
drop(logger);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_default_filter_shows_photocraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
+53 -15
View File
@@ -49,6 +49,8 @@ mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_window;
// Pure logic is tested on every platform; only Linux runs the check.
#[cfg(not(target_arch = "wasm32"))]
mod external_svg;
#[cfg(any(target_os = "linux", test))]
mod linux_libs;
mod logging;
@@ -66,6 +68,7 @@ mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
mod startup_report;
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
@@ -75,12 +78,11 @@ mod window_frame;
use photocraft_engine::Session;
use photocraft_ui_egui::PhotocraftApp;
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
const APP_ID: &str = "ai.storyteller.photocraft";
use photocraft_ui_egui::os_window::APP_ID;
/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption
/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS
/// keeps its traffic lights over the integrated title strip.
/// Windows and Linux can use the app's title bar, with its own caption buttons and edge resizing
/// (`photocraft_ui_egui::titlebar`); the preference below selects the mode. macOS always keeps its
/// system traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
@@ -285,12 +287,19 @@ fn control_setup(
cli_port: Option<u16>,
auth: photocraft_automation::args::ControlArgs,
automation: &photocraft_automation::args::AutomationArgs,
) -> Option<(u16, String, photocraft_automation::workspace::AuthorizedWorkspace)> {
) -> Option<(std::net::TcpListener, String, photocraft_automation::workspace::AuthorizedWorkspace)> {
let (port, warning) = control_port_for(cli_port, &integrations(services::prefs_file().as_deref()));
if let Some(warning) = &warning {
eprint!("{warning}");
}
let port = port?;
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => listener,
Err(error) => {
eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {error}");
std::process::exit(2);
}
};
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
@@ -313,7 +322,7 @@ fn control_setup(
std::process::exit(2);
}
};
Some((port, token, workspace))
Some((listener, token, workspace))
}
/// Record this start's backend in the GPU startup marker, if the marker is locked.
@@ -331,11 +340,14 @@ fn write_start_marker(sentinel: &gpu_startup::SharedSentinel, plan: &gpu_startup
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
// Before anything starts a thread: may re-execute this process with glibc tuned to return
// the memory of closed documents (#8743).
photocraft_engine::malloc_tuning::apply();
// Then, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
#[cfg(not(target_arch = "wasm32"))]
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::PHOTOCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(done) = craft_update::self_update::finish(&photocraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("PhotoCraft update helper failed: {e}");
std::process::exit(1);
@@ -385,12 +397,14 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let _ = (new_instance, &launch_options);
// Bind and authenticate the authorized control listener before display/GPU initialization.
let control = control_setup(control_port, auth, &automation);
#[cfg(target_os = "linux")]
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
#[cfg(target_os = "linux")]
let session = linux_display_session();
let control = control_setup(control_port, auth, &automation);
// Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that
// launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -473,6 +487,10 @@ fn main() -> eframe::Result {
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
let keep_marker = gpu_startup::keep_marker_after_error(&plan, os);
// For the failure dialog below: the plan itself moves into the window closure. With
// `WGPU_BACKEND` set (`plan.env`) wgpu picks the backend, so name that rather than `auto`.
let started_renderer = plan.env.clone().unwrap_or_else(|| plan.backend.name().to_string());
let started_note = plan.reason.clone();
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_callback = app_created.clone();
let started_sentinel = sentinel.clone();
@@ -599,8 +617,8 @@ fn main() -> eframe::Result {
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
}
});
if let Some((port, token, _)) = control {
let rx = control_server::start(port, token, cc.egui_ctx.clone());
if let Some((listener, token, _)) = control {
let rx = control_server::start(listener, token, cc.egui_ctx.clone());
app = app.with_control(rx);
}
#[cfg(target_os = "macos")]
@@ -632,7 +650,14 @@ fn main() -> eframe::Result {
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
// precise crosshair for painting tools while it is toggled on (#1758).
app.services.caps_lock = caps_lock::service(cc);
app.services.screen_pick = screen_color::service(cc, app.services.is_wayland);
// Pick through the portal on a Wayland desktop even when the window went through
// Xwayland (a pen, or the X11 display-server preference): Xwayland's root window has
// no desktop pixels to read (#8977).
#[cfg(target_os = "linux")]
let portal = screen_color::uses_portal(native_session);
#[cfg(not(target_os = "linux"))]
let portal = false;
app.services.screen_pick = screen_color::service(cc, portal);
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader
// write into the stylus feed. The monitor goes in here, not before the event loop:
// AppKit's shared application only exists once winit created it (#759).
@@ -710,6 +735,16 @@ fn main() -> eframe::Result {
}
}
}
// A launch that fails before a window exists leaves nothing on screen, and a desktop launch
// has no console for the `Err` above: log it, then say why in a native dialog with the log
// file to attach (#7643). A terminal or automation launch can read standard error or the
// exit status instead, so it keeps the silent exit.
if let Err(error) = &result {
log::error!("PhotoCraft could not start: {error}");
if startup_report::needs_dialog(control_port.is_some()) {
startup_report::show(&error.to_string(), &started_renderer, started_note.as_deref(), logger.and_then(logging::AppLogger::path).as_deref());
}
}
result
}
@@ -911,9 +946,12 @@ mod tests {
let dir = std::env::temp_dir().join(format!("pc-titlebar-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("prefs.json");
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences file: the default");
let default_custom = super::CUSTOM_TITLEBAR;
assert_eq!(super::custom_titlebar(None), default_custom, "no preferences file: the platform default");
std::fs::write(&file, "not json").unwrap();
assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR, "an unreadable file: the default");
assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an unreadable file: the platform default");
std::fs::write(&file, r#"{"interface":{}}"#).unwrap();
assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an older preference file: the platform default");
std::fs::write(&file, r#"{"interface":{"systemTitleBar":true}}"#).unwrap();
assert!(!super::custom_titlebar(Some(&file)));
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
@@ -0,0 +1,222 @@
//! Opt-in local acceptance. All artwork paths come from the test environment; originals are read-only.
use super::{native, vector_conversion};
use base64::Engine as _;
use photocraft_doc::{Document, LayerContent, LayerId, SmartSource};
use photocraft_ui_egui::{ExternalSvgSession, ExternalVectorSource};
use serde_json::{Value, json};
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::{Arc, mpsc};
use std::time::{Duration, Instant};
/// Exercise the real CLI's same-format Save, the native watcher and PhotoCraft's transaction.
#[test]
#[ignore = "set VECTORCRAFT_TEST_EDITOR and optionally PHOTO_NATIVE_LOGO_PSD/PHOTO_NATIVE_CONTAINER"]
#[allow(clippy::cognitive_complexity, reason = "one opt-in walk through a real editor, step by step")]
fn original_format_real_editor_save_watcher_and_psd_roundtrip() {
let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR");
let editor = Path::new(&editor);
let mut photo = photocraft_engine::Session::new();
let vector_path = std::env::var_os("PHOTO_NATIVE_VECTOR");
let vector_original = vector_path.as_ref().map(|path| std::fs::read(path).unwrap());
let original_path = std::env::var_os("PHOTO_NATIVE_LOGO_PSD");
let original = original_path.as_ref().map(|path| std::fs::read(path).unwrap());
let mut parent_before = None;
let id;
if let Some(bytes) = &original {
photo.add_document(photocraft_io::import("Logo.psd", bytes).unwrap().document, None);
if let Ok(name) = std::env::var("PHOTO_NATIVE_CONTAINER") {
parent_before = Some(photo.active().unwrap().doc.clone());
let layer = find_source(&photo.active().unwrap().doc, &name).expect("nested logo container");
photo.execute("layer.smartObjects.editContents", json!({"layer":layer.0})).unwrap();
}
let name = std::env::var("PHOTO_NATIVE_SOURCE").unwrap_or("Vector Smart Object.ai".into());
id = find_source(&photo.active().unwrap().doc, &name).expect("visible embedded vector");
} else {
photo.execute("file.new", json!({"width":32,"height":24})).unwrap();
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect x="1" y="1" width="3" height="2" fill="red"/></svg>"#;
let placed = photocraft_engine::file_cmds::place_bytes(&mut photo, "Artwork.svg", svg.to_vec(), None, &json!({"smartObject":true})).unwrap();
id = LayerId(placed["layer"].as_u64().unwrap());
// Generate native sources with our writer, or use a read-only local vector fixture.
let (format, bytes) = if let Some(bytes) = &vector_original {
let extension = Path::new(vector_path.as_ref().unwrap()).extension().unwrap().to_str().unwrap().to_ascii_lowercase();
(extension, bytes.clone())
} else {
let owned = native::OwnedSvg::create(svg).unwrap();
let format = std::env::var("PHOTO_NATIVE_FORMAT").unwrap_or("ai".into());
let path = owned.root.join(format!("Input.{format}"));
run_commands(
editor,
&owned.root,
vec![
("document.open", json!({"path":owned.file})),
("document.export", json!({"path":path,"format":format,"preserveEditing":true,"useArtboards":true})),
],
);
(format, std::fs::read(path).unwrap())
};
let state = photo.active_mut().unwrap();
let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") };
sm.source = SmartSource::Embedded { file_name: format!("Artwork.{format}"), bytes: Arc::new(bytes) };
}
let handoff = photo.execute("layer.smartObjects.exportSvg", json!({"layer":id.0})).unwrap();
let source = ExternalVectorSource {
bytes: Arc::new(base64::engine::general_purpose::STANDARD.decode(handoff["sourceBase64"].as_str().unwrap()).unwrap()),
extension: handoff["extension"].as_str().unwrap().into(),
size: serde_json::from_value(handoff["sourceSize"].clone()).unwrap(),
};
let before = photo.active().unwrap().doc.clone();
let (_send, stop) = mpsc::channel();
let (owned, initial) = vector_conversion::prepare(editor, &source, &stop, true).unwrap();
assert_eq!(owned.file.extension().unwrap().to_str().unwrap(), source.extension);
assert!(photo.active().unwrap().doc == before, "preparation must not change the embedded original");
let file = owned.file.clone();
let root = owned.root.clone();
let mut watcher = native::start_native_test(owned, initial.clone(), editor.to_path_buf(), source.clone()).unwrap();
std::thread::sleep(Duration::from_millis(300));
assert!(watcher.poll_vector().is_none(), "opening must not accept the regenerated private AI working copy");
// A small visible edit, ordinary Save to the already-open file, no conversion to SVG.
run_commands(
editor,
&root,
vec![
("document.open", json!({"path":file})),
("paint.setFill", json!({"color":"#00ff00"})),
("shape.rectangle", json!({"x":2,"y":2,"width":3,"height":2})),
("file.save", json!({})),
],
);
let saved = std::fs::read(&file).unwrap();
eprintln!("Native save: format={}, bytes={}", source.extension, saved.len());
assert!(saved != initial, "Save did not update the native working file");
assert!(photocraft_io::vector_preview::is_native_source(&saved));
assert!(!photocraft_io::svg::is_svg(&saved));
let deadline = Instant::now() + Duration::from_secs(90);
let snapshot = loop {
assert!(Instant::now() < deadline, "saved native vector never reached watcher");
if let Some(result) = watcher.poll_vector() {
break result.unwrap();
}
std::thread::sleep(Duration::from_millis(25));
};
assert!(snapshot.bytes == saved, "watcher snapshot differs from saved native bytes");
let params = json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"extension":source.extension,"sourceBase64":base64::engine::general_purpose::STANDARD.encode(&saved),"previewSvg":String::from_utf8(snapshot.preview_svg.unwrap()).unwrap()});
assert_eq!(photo.execute("layer.smartObjects.updateVectorSource", params).unwrap()["updated"], true);
let after = photo.active().unwrap().doc.clone();
let LayerContent::Smart(previous) = &before.layer(id).unwrap().content else { panic!("smart") };
let LayerContent::Smart(changed) = &after.layer(id).unwrap().content else { panic!("smart") };
assert_eq!(changed.transform, previous.transform);
assert_eq!(changed.perspective, previous.perspective);
assert_eq!(changed.warp, previous.warp);
assert_eq!(changed.smart_filters, previous.smart_filters);
assert!(embedded(&after, changed).unwrap().1 == saved, "native source was replaced with different bytes");
assert!(photo.undo());
assert!(photo.active().unwrap().doc == before, "Undo did not restore original source state");
assert!(photo.redo());
assert!(photo.active().unwrap().doc == after, "Redo did not restore saved native source state");
let output = photocraft_io::export(&after, "Roundtrip.psd", &Default::default()).unwrap();
let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document;
let layer = find_source(&reopened, &embedded(&after, changed).unwrap().0).unwrap();
let LayerContent::Smart(sm) = &reopened.layer(layer).unwrap().content else { panic!("smart") };
assert!(embedded(&reopened, sm).unwrap().1 == saved, "PSD reopen did not retain the saved native bytes");
assert!(sm.vector_preview == changed.vector_preview, "PSD did not retain the source-bound preview");
assert!(
sm.transform.m.iter().zip(changed.transform.m).all(|(a, b)| (a - b).abs() < 1e-9),
"PSD corner reconstruction changed placement beyond floating-point tolerance"
);
assert!(photocraft_engine::smart_cmds::render(&reopened, sm).unwrap().as_ref() == changed.cache.as_ref(), "reopened native preview changed appearance");
if let Some(parent_before) = parent_before {
photo.execute("layer.smartObjects.saveContents", json!({})).unwrap();
photo.set_active(0);
let parent_after = photo.active().unwrap().doc.clone();
assert!(photo.undo());
assert!(photo.active().unwrap().doc == parent_before, "parent Undo changed original content");
assert!(photo.redo());
assert!(photo.active().unwrap().doc == parent_after, "parent Redo changed saved content");
let output = photocraft_io::export(&parent_after, "Roundtrip.psd", &Default::default()).unwrap();
let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document;
assert!(contains_source(&reopened, &saved, 0), "parent PSD lost its nested native vector");
}
if let Some(path) = vector_path {
assert!(std::fs::read(path).unwrap() == vector_original.unwrap(), "original vector changed")
}
if let Some(path) = original_path {
assert!(std::fs::read(path).unwrap() == original.unwrap(), "original PSD changed")
}
}
fn run_commands(editor: &Path, root: &Path, commands: Vec<(&str, Value)>) {
let cli = editor.parent().unwrap().join(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" });
let output_path = root.join("TestResponses.jsonl");
let output = std::fs::File::create(&output_path).unwrap();
let mut child = Command::new(cli)
.args(["mcp", "--headless", "--automation-read-root"])
.arg(root)
.arg("--automation-write-root")
.arg(root)
.stdin(Stdio::piped())
.stdout(output)
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
let count = commands.len();
for (index, (command, params)) in commands.into_iter().enumerate() {
writeln!(
input,
"{}",
json!({"jsonrpc":"2.0","id":index+1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}})
)
.unwrap();
}
drop(input);
let deadline = Instant::now() + Duration::from_secs(90);
loop {
if let Some(status) = child.try_wait().unwrap() {
assert!(status.success());
break;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("editor test timed out")
}
std::thread::sleep(Duration::from_millis(25));
}
let responses = std::fs::read_to_string(output_path).unwrap();
for id in 1..=count {
let response = responses.lines().filter_map(|line| serde_json::from_str::<Value>(line).ok()).find(|r| r["id"] == id).unwrap();
assert!(response.get("error").is_none() && response["result"]["isError"] == false, "editor command {id} failed");
}
}
fn embedded<'a>(doc: &'a Document, sm: &'a photocraft_doc::SmartObject) -> Option<(String, &'a [u8])> {
match &sm.source {
SmartSource::Embedded { file_name, bytes } => Some((file_name.clone(), bytes)),
SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&doc.metadata, path),
}
}
fn find_source(doc: &Document, name: &str) -> Option<LayerId> {
doc.walk().into_iter().find_map(|(path, _, layer)| {
if !doc.shown_at(&path) {
return None;
}
let LayerContent::Smart(sm) = &layer.content else { return None };
embedded(doc, sm).filter(|(file, _)| file == name).map(|_| layer.id)
})
}
fn contains_source(doc: &Document, saved: &[u8], depth: usize) -> bool {
if depth > 4 {
return false;
}
doc.walk().into_iter().any(|(_, _, layer)| {
let LayerContent::Smart(sm) = &layer.content else { return false };
let Some((name, bytes)) = embedded(doc, sm) else { return false };
bytes == saved
|| ((bytes.starts_with(b"8BPS") || photocraft_format::is_pcraft(bytes))
&& photocraft_io::import(&name, bytes).is_ok_and(|child| contains_source(&child.document, saved, depth + 1)))
})
}
+15
View File
@@ -38,6 +38,12 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Servic
Pending { receiver: rx, cancelled }
}))
}
/// Whether to pick through the desktop portal: on a Wayland desktop, whichever display server the
/// window opened on. Xwayland serves only its own windows, so reading its root window fails (#8977).
#[cfg(target_os = "linux")]
pub fn uses_portal(desktop: crate::linux_libs::DisplaySession) -> bool {
desktop == crate::linux_libs::DisplaySession::Wayland
}
#[derive(Clone, Copy)]
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", target_os = "windows", test)), allow(dead_code))]
struct Placement {
@@ -480,6 +486,15 @@ mod tests {
let offset = (y * first.width + x) * 4;
assert_eq!(&first.rgba[offset..offset + 4], &[255, 128, 64, 255]);
}
#[cfg(target_os = "linux")]
#[test]
fn a_wayland_desktop_picks_through_the_portal_even_with_an_xwayland_window() {
use crate::linux_libs::DisplaySession;
// A pen or the X11 display-server preference opens the window through Xwayland, but the
// session is still Wayland: the X11 grab fails there with BadMatch (#8977).
assert!(uses_portal(DisplaySession::Wayland));
assert!(!uses_portal(DisplaySession::X11));
}
#[test]
fn screen_capture_matches_negative_and_hidpi_monitor_geometry() {
let m = [Placement { index: 2, x: -3840, y: 0, width: 3840, height: 2160 }];
+52 -17
View File
@@ -92,6 +92,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenRaster", &["ora"]),
("PNG", &["png"]),
("JPEG", &["jpg", "jpeg"]),
("JPEG XL", &["jxl"]),
("WebP", &["webp"]),
("TIFF", &["tif", "tiff"]),
("Targa", &["tga"]),
@@ -360,9 +361,20 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
}
/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389).
fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> {
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
/// [`write_atomic`] for an encoded save, reporting the fraction written (a background save's
/// progress, #3389). A PSD/PSB is serialized straight into the temporary file, so the whole file
/// is never held in memory next to its model (#6607).
fn write_encoded_atomic(path: &Path, encoded: &photocraft_io::Encoded, progress: &dyn Fn(f32)) -> Result<(), String> {
match encoded {
photocraft_io::Encoded::Bytes(bytes) => photocraft_format::atomic_write_progress(path, bytes, progress),
photocraft_io::Encoded::Psd(_) => {
// Only the progress bar's scale: a model whose length can't be worked out fails the
// write the same way, through the atomic write's "not changed" error.
let len = encoded.encoded_len().unwrap_or(0);
photocraft_format::atomic_write_stream_progress(path, len, progress, &mut |w| encoded.write_to(w))
}
}
.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
@@ -412,26 +424,26 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i
const ENCODE_SHARE: f32 = 0.85;
/// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017):
/// encode, then write through `write`. Cancelling while encoding leaves the target untouched;
/// once writing starts the save can't be cancelled. The encoder's progress fills the first
/// encode, then write through `write` (a PSD/PSB is serialized as it is written). Cancelling
/// while encoding leaves the target untouched; once writing starts the save can't be cancelled. The encoder's progress fills the first
/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389).
fn save_through(
doc: &Document,
path: &str,
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
write: impl Fn(&str, &photocraft_io::Encoded, &dyn Fn(f32)) -> Result<(), String>,
) -> Result<Vec<String>, String> {
ctl.progress(0.0, "Encoding");
let opts = export_options(settings);
let cancelled = || ctl.check().is_err();
let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, "");
let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress);
let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
let r = crate::crash_guard::guard("Save", || photocraft_io::encode_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
ctl.commit()?;
ctl.progress(ENCODE_SHARE, "Writing");
// The last bit is the flush and rename after the last chunk: 100% only once they're done.
write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
write(path, &r.encoded, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
ctl.progress(1.0, "");
Ok(r.warnings)
}
@@ -442,7 +454,7 @@ fn save_file(
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
) -> Result<Vec<String>, String> {
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
save_through(doc, path, settings, ctl, |p, encoded, progress| write_encoded_atomic(Path::new(p), encoded, progress))
}
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
@@ -496,6 +508,11 @@ fn guarded_clipboard_get(mut read: impl FnMut() -> Result<Option<(u32, u32, Vec<
Box::new(move || crate::crash_guard::guard("Paste", &mut read))
}
/// SVG reads use the same recovery boundary as raster clipboard reads.
fn guarded_clipboard_svg(mut read: impl FnMut() -> Result<Option<Vec<u8>>, String> + 'static) -> photocraft_ui_egui::ClipboardGetSvgFn {
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)))
}
@@ -615,6 +632,18 @@ fn clipboard_text(read: Result<String, arboard::Error>) -> Result<Option<String>
}
}
/// Keep SVG source for Paste As without accepting an unbounded clipboard string as a new copy.
fn svg_clipboard_source(text: Option<String>) -> Result<Option<Vec<u8>>, String> {
let Some(text) = text else { return Ok(None) };
if !photocraft_io::svg::is_svg(text.as_bytes()) {
return Ok(None);
}
if text.len() > 16 << 20 {
return Err("SVG clipboard data exceeds the 16 MiB limit".into());
}
Ok(Some(text.into_bytes()))
}
/// The shell command that starts this install under XWayland, where winit delivers file drops
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
@@ -634,12 +663,6 @@ fn shell_word(s: &str) -> String {
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
}
/// The release-check transport, or `None` inside Flatpak: `flatpak update` owns updates there.
/// The sandbox has network (for the `--control` server), which does not change this.
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
flatpak_id.is_none().then(crate::updates::service)
}
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
@@ -662,7 +685,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
});
let automation_save = automation.clone().map(|workspace| {
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string()))
save_through(doc, path, settings, ctl, |p, encoded, _| workspace.write_stream(p, &mut |w| encoded.write_to(w)).map_err(|e| e.to_string()))
}) as photocraft_ui_egui::SaveFileFn
});
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
@@ -704,7 +727,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
beep: Some(platform_beep),
check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()),
check_updates: Some(crate::updates::service()),
clipboard_set_image: Some({
let clip = clip.clone();
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
@@ -716,6 +739,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
})
}),
open_external_svg: Some(Box::new(crate::external_svg::open)),
clipboard_get_image: Some({
let clip = clip.clone();
let unreadable = clip_unreadable.clone();
@@ -752,6 +776,17 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
)
})
}),
clipboard_get_svg: Some({
let clip = clip.clone();
guarded_clipboard_svg(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().map_err(|e| e.to_string())?),
};
svg_clipboard_source(clipboard_text(cb.get_text())?)
})
}),
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)))),
+98 -13
View File
@@ -4,6 +4,41 @@ use photocraft_format::list_recovery;
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
/// #6607: a PSD/PSB save streams the file model into the temporary file, which then holds
/// exactly the exported bytes; a serializer error halfway leaves the old file and no
/// temporary file behind.
#[test]
fn a_psd_save_streams_the_exported_bytes_and_keeps_the_old_file_on_error() {
let dir = temp("stream");
let mut doc = Document::new("streamed", photocraft_geom::Size::new(64, 48), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8);
let fmt = doc.pixel_format();
let px: Vec<u8> = (0..64 * 48 * fmt.bytes_per_pixel()).map(|i| (i * 7 % 251) as u8).collect();
doc.layers
.push(photocraft_doc::Layer::new("A", photocraft_doc::LayerContent::Raster(photocraft_raster::Surface::from_interleaved(fmt, doc.bounds(), &px))));
let opts = photocraft_io::ExportOptions::default();
for name in ["a.psd", "a.psb"] {
let path = dir.join(name);
std::fs::write(&path, b"old").unwrap();
let want = photocraft_io::export(&doc, name, &opts).unwrap().bytes;
let r = photocraft_io::encode_with(&doc, name, &opts, &photocraft_raster::Interrupt::NONE).unwrap();
let photocraft_io::Encoded::Psd(mut file) = r.encoded else { panic!("{name}: not streamed") };
let seen = std::cell::RefCell::new(Vec::new());
write_encoded_atomic(&path, &photocraft_io::Encoded::Psd(file.clone()), &|f| seen.borrow_mut().push(f)).unwrap();
assert!(std::fs::read(&path).unwrap() == want, "{name}: the saved file differs from the export");
assert_eq!(seen.borrow().last().copied(), Some(1.0), "{name}");
let info = file.layer_info.as_mut().unwrap();
let layer = info.layers[0].clone();
info.layers = vec![layer; 32768];
let e = write_encoded_atomic(&path, &photocraft_io::Encoded::Psd(file), &|_| {}).unwrap_err();
assert!(e.contains("32767") && e.contains("not changed"), "{name}: {e}");
assert!(std::fs::read(&path).unwrap() == want, "{name}: a failed save replaced the file");
let temps: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().ends_with(".tmp")).collect();
assert!(temps.is_empty(), "{name}: left behind {temps:?}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #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]
@@ -182,9 +217,8 @@ fn open_filters_keep_literal_extensions_on_windows_and_macos() {
}
#[test]
fn flatpak_builds_have_no_update_transport() {
assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none());
assert!(update_transport(None).is_some());
fn native_services_provide_update_transport() {
assert!(native(None).check_updates.is_some());
}
#[test]
@@ -253,7 +287,7 @@ fn every_save_dialog_leads_with_its_own_extension() {
app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap();
let _ = invoke(&mut app, "file.export.quickExportAsPng");
}
for format in ["gif", "png8", "png24", "jpeg", "wbmp"] {
for format in ["gif", "png8", "png24", "jpeg", "webp", "wbmp"] {
dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format}));
}
app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
@@ -266,13 +300,13 @@ fn every_save_dialog_leads_with_its_own_extension() {
let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect();
// The extension each save above should suggest, in order.
let want = [
"psd", // Save As untitled
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
"psd", // Save a Copy
"png jpg webp tif tga", // Export As
"png jpg gif webp", // Quick Export
"gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices
"csv pcpresets", // Measurement Log, Export Presets
"psd", // Save As untitled
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
"psd", // Save a Copy
"png jpg webp tif tga", // Export As
"png jpg gif webp", // Quick Export
"gif png png jpg webp wbmp html", // Save for Web: gif png8 png24 jpeg webp wbmp, then with slices
"csv pcpresets", // Measurement Log, Export Presets
]
.join(" ");
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
@@ -313,10 +347,15 @@ fn save_as_offers_only_writable_formats_including_ico() {
let subtype = name.rsplit('.').next().unwrap_or_default();
assert!(!filters.iter().any(|(_, exts)| exts.iter().any(|e| e == subtype)), "{name}: .{subtype} is offered");
}
// File › Open lists every file Save As writes, also in that format's own type (PDF is written
// but not read yet).
// File Open lists readable Save As formats in their own type. Export-only formats must
// stay absent from both Open filters rather than advertising an unavailable decoder.
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| **e != "pdf") {
if photocraft_codecs::from_extension(ext).is_some_and(|format| !format.caps().read) {
assert!(!OPEN_EXTS.contains(ext), "{name}: export-only .{ext} must not appear in All Formats");
assert!(!OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: export-only .{ext} must not appear in an Open type");
continue;
}
assert!(OPEN_EXTS.contains(ext), "{name}: Open's All Formats lacks .{ext}");
assert!(OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: no Open type lists .{ext}");
}
@@ -705,6 +744,33 @@ fn from_text(text: &str) -> Option<(u32, u32, Vec<u8>)> {
clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| Ok(Some(text.to_string()))).expect("no read fails")
}
#[test]
fn svg_clipboard_source_preserves_markup_and_bounds_its_size() {
let bytes = svg_clipboard_source(Some(HALF_RED.into())).unwrap().unwrap();
assert_eq!(bytes, HALF_RED.as_bytes());
for text in [None, Some("plain text".into()), Some("<p>text</p>".into())] {
assert_eq!(svg_clipboard_source(text), Ok(None));
}
let mut oversized = HALF_RED.to_string();
oversized.extend(std::iter::repeat_n(' ', (16 << 20) + 1));
assert!(svg_clipboard_source(Some(oversized)).is_err());
}
#[test]
fn a_panicking_svg_clipboard_read_is_reported_and_can_be_retried() {
let panics = Rc::new(Cell::new(true));
let fail = panics.clone();
let mut get = guarded_clipboard_svg(move || {
if fail.get() {
panic!("SVG clipboard reader bug");
}
Ok(Some(HALF_RED.as_bytes().to_vec()))
});
assert!(get().unwrap_err().contains("Paste failed"));
panics.set(false);
assert_eq!(get().unwrap().unwrap(), HALF_RED.as_bytes());
}
fn pixel(img: &(u32, u32, Vec<u8>), x: u32, y: u32) -> [u8; 4] {
let at = ((y * img.0 + x) * 4) as usize;
img.2[at..at + 4].try_into().unwrap()
@@ -882,3 +948,22 @@ fn a_panicking_clipboard_read_is_reported_not_a_crash() {
let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug"));
assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed"));
}
/// #8827: what TIFF Options sets in `ExportSettings` reaches the file the app writes: the
/// compression, Save Transparency and Save Image Pyramid.
#[test]
fn tiff_options_reach_the_written_file() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap();
let doc = s.active().unwrap().doc.clone();
let settings = photocraft_ui_egui::ExportSettings {
tiff_compression: photocraft_codecs::TiffCompression::Lzw,
tiff_transparency: Some(false),
tiff_pyramid: true,
..Default::default()
};
let bytes = photocraft_io::export(&doc, "t.tif", &export_options(&settings)).unwrap().bytes;
let info = photocraft_codecs::tiff_info(&bytes).unwrap();
assert_eq!(info.pages.iter().map(|p| (p.width, p.compression)).collect::<Vec<_>>(), [(200, 5), (100, 5), (50, 5)]);
assert!(!photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha());
}
+104
View File
@@ -0,0 +1,104 @@
//! Telling the user why a launch failed when there is no window to say it in (#7643).
//!
//! A desktop launch has no console — release builds are GUI-subsystem apps on Windows — so the
//! `Err` that ends `main` used to vanish and PhotoCraft "just closed". This shows one native
//! dialog with the error, the renderer that was tried and the log file to attach. The text is
//! English diagnostics, like Help › System Info, so a bug report reads the same in every language.
//!
//! Terminal and automation launches keep the old behaviour: they can read standard error or the
//! exit status, and a modal dialog would leave a script waiting.
use std::io::IsTerminal;
use std::path::Path;
/// Whether a failed launch needs the dialog: no terminal under standard error, no control client
/// (an agent launch reads the exit status instead of clicking OK), and a screen to put it on.
pub fn dialog_wanted(stderr_is_terminal: bool, automation: bool, display: bool) -> bool {
!stderr_is_terminal && !automation && display
}
/// Whether this launch asks for the dialog (see [`dialog_wanted`]).
pub fn needs_dialog(automation: bool) -> bool {
dialog_wanted(std::io::stderr().is_terminal(), automation, has_display())
}
/// Whether there is a screen for a dialog: always on Windows and macOS; on Linux a headless box
/// (a CI runner, an SSH session — `startup_without_display` removes both variables) has none.
fn has_display() -> bool {
#[cfg(target_os = "linux")]
{
["DISPLAY", "WAYLAND_DISPLAY"].iter().any(|key| std::env::var_os(key).is_some_and(|v| !v.is_empty()))
}
#[cfg(not(target_os = "linux"))]
{
true
}
}
/// The dialog's text: the error, the renderer that was tried and why it wasn't the preference,
/// the version and, when one was opened, the log file.
pub fn text(error: &str, renderer: &str, note: Option<&str>, version: &str, log: Option<&Path>) -> String {
let mut text = format!("PhotoCraft could not start.\n\n{error}\n\nRenderer: {renderer}");
if let Some(note) = note {
text.push_str(&format!("\nStartup: {note}"));
}
text.push_str(&format!("\nVersion: {version}"));
if let Some(path) = log {
text.push_str(&format!("\nLog file: {}", path.display()));
}
text
}
/// Show [`text`] in a native error dialog. Called after the event loop returned, so nothing is
/// repainting and the launch was about to fail anyway.
pub fn show(error: &str, renderer: &str, note: Option<&str>, log: Option<&Path>) {
let version = photocraft_engine::build_info::long_version();
let body = text(error, renderer, note, &version, log);
log::info!("showing the startup-failure dialog");
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(photocraft_ui_egui::i18n::t("PhotoCraft could not start"))
.set_description(body)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_desktop_launch_asks_the_user_and_a_script_does_not() {
assert!(dialog_wanted(false, false, true), "no console: the error would vanish");
assert!(!dialog_wanted(true, false, true), "a terminal shows the error itself");
assert!(!dialog_wanted(false, true, true), "an automation client reads the exit status");
assert!(!dialog_wanted(false, false, false), "a headless launch has no screen for a dialog");
}
#[test]
fn the_text_names_the_error_renderer_version_and_log() {
let log = Path::new("/home/me/.config/photocraft/logs/photocraft.log");
let body = text("no adapter for Vulkan", "dx12", None, "0.15.1 (abc1234)", Some(log));
for part in [
"could not start",
"no adapter for Vulkan",
"Renderer: dx12",
"Version: 0.15.1 (abc1234)",
"Log file: /home/me/.config/photocraft/logs/photocraft.log",
] {
assert!(body.contains(part), "{part:?} missing from {body:?}");
}
assert!(!body.contains("Startup:"), "no fallback to explain: {body:?}");
// A start whose log file could not be created says so by leaving the line out.
assert!(!text("boom", "cpu", None, "0.15.1", None).contains("Log file:"));
}
/// A crash fallback is the confusing case: the app picks a renderer the user never chose, so
/// the dialog has to say why as well as which.
#[test]
fn a_fallback_that_was_not_the_preference_is_explained() {
let note = "the previous start didn't finish on vulkan; using dx12";
let body = text("device lost", "dx12", Some(note), "0.15.1", None);
assert!(body.contains(&format!("Renderer: dx12\nStartup: {note}\n")), "{body:?}");
}
}
+1 -1
View File
@@ -22,6 +22,6 @@ pub fn service() -> CheckFn {
fn check() -> CheckResult {
let http = craft_update::Http::new("PhotoCraft/Update")?;
let found = craft_update::check_for_update(&craft_update::suite::PHOTOCRAFT, &http, photocraft_engine::build_info::VERSION)?;
let found = craft_update::check_for_update(&photocraft_ui_egui::UPDATE_APP, &http, photocraft_engine::build_info::VERSION)?;
Ok(found.map(|release| Release { version: release.tag.trim_start_matches(['v', 'V']).to_string(), url: release.html_url }))
}
+342
View File
@@ -0,0 +1,342 @@
//! Bounded display previews for native vector editing. The working source retains its format.
//! No document metadata supplies a process, command line or output path.
use super::native::OwnedSvg;
use photocraft_ui_egui::ExternalVectorSource;
use std::io::{Read, Seek, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
/// Validate the source using a private preview, then expose only an original-format copy.
/// `regenerate_ai` is true only when the interactive editor is VectorCraft: other editors
/// (Illustrator, a chosen program) get the original AI bytes.
pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, regenerate_ai: bool) -> Result<(OwnedSvg, Vec<u8>), String> {
render_preview(editor, source, stop)?;
// Foreign AI opens read-only for ordinary Save in VectorCraft. Regenerate only this
// private AI working copy with editable AI data; never weaken that source-file protection.
// Comparing the watcher with this initial copy prevents an open-only/no-op replacement.
let regenerate = regenerate_ai && source.extension == "ai";
let initial = if regenerate { run_export(editor, source, stop, "ai")? } else { source.bytes.as_ref().clone() };
if regenerate {
let prepared = ExternalVectorSource { bytes: std::sync::Arc::new(initial.clone()), extension: source.extension.clone(), size: source.size };
render_preview(editor, &prepared, stop)?;
}
let owned = OwnedSvg::create_format(&initial, &source.extension)?;
Ok((owned, initial))
}
/// Render a stable source snapshot inside private MCP roots; never rewrite the editor's file.
pub(super) fn render_preview(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> Result<Vec<u8>, String> {
let converted = if source.extension == "svg" { source.bytes.as_ref().clone() } else { run_export(editor, source, stop, "svg")? };
source_svg(&converted, source.size)
}
/// Convert `source` to `format` (`svg` or `ai`) with the `vectorcraft-cli` next to `editor`,
/// confined to a private temporary read/write root. Errors on a format/magic mismatch, a
/// missing or linked CLI, a failed open/export reply, cancellation via `stop`, or the
/// 60-second deadline; the child process is killed and reaped on any failure.
fn run_export(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, format: &str) -> Result<Vec<u8>, String> {
if !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") {
return Err("Unsupported embedded vector source type".into());
}
if !matches!(format, "svg" | "ai")
|| !photocraft_io::vector_preview::is_native_source(&source.bytes)
|| (source.extension == "pdf" && !source.bytes.starts_with(b"%PDF-"))
|| (source.extension == "eps" && source.bytes.starts_with(b"%PDF-"))
{
return Err("The saved bytes do not match the original vector format".into());
}
let owned = OwnedSvg::create_format(&source.bytes, format)?;
let cli_name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" };
let cli = editor.parent().ok_or("The editor has no installation directory")?.join(cli_name);
// Selecting an editor explicitly also selects its adjacent VectorCraft CLI; PATH and the
// embedded file name never participate in executable discovery.
let metadata =
std::fs::symlink_metadata(&cli).map_err(|_| "Embedded AI/PDF/EPS editing needs vectorcraft-cli next to the selected VectorCraft executable")?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err("VectorCraft CLI must be a regular file".into());
}
let input = owned.root.join(format!("Source.{}", source.extension));
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&input).map_err(|e| format!("Cannot create vector input: {e}"))?;
file.write_all(&source.bytes).map_err(|e| format!("Cannot write vector input: {e}"))?;
drop(file);
let commands = export_commands(&input, &owned.file, format)?;
// MCP's headless roots apply to every thread and file reader, including references in
// hostile embedded artwork. Ordinary `convert` does not establish those restrictions.
let responses_path = owned.root.join("Responses.jsonl");
let mut responses_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&responses_path)
.map_err(|e| format!("Cannot create conversion response file: {e}"))?;
let responses_output = responses_file.try_clone().map_err(|e| format!("Cannot prepare conversion response handle: {e}"))?;
let mut child = Command::new(cli)
.args(["mcp", "--headless", "--automation-read-root"])
.arg(&owned.root)
.arg("--automation-write-root")
.arg(&owned.root)
.current_dir(&owned.root)
.stdin(Stdio::piped())
.stdout(Stdio::from(responses_output))
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Cannot start VectorCraft conversion: {e}"))?;
let result = (|| {
let mut stdin = child.stdin.take().ok_or("VectorCraft conversion input is unavailable")?;
for (id, command, params) in commands {
let request = serde_json::json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}});
writeln!(stdin, "{request}").map_err(|e| format!("Cannot send vector conversion request: {e}"))?;
}
drop(stdin);
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if stop.try_recv().is_ok() {
return Err("Vector editing cancelled".into());
}
if let Some(status) = child.try_wait().map_err(|e| format!("Cannot wait for vector conversion: {e}"))? {
if !status.success() {
return Err("VectorCraft could not convert the embedded vector".into());
}
break;
}
if Instant::now() >= deadline {
return Err("VectorCraft conversion timed out".into());
}
std::thread::sleep(Duration::from_millis(25));
}
let mut responses = String::new();
// Read the retained handle, never reopen a pathname an input processor could replace.
responses_file.rewind().map_err(|e| format!("Cannot seek conversion responses: {e}"))?;
responses_file.take(1024 * 1024 + 1).read_to_string(&mut responses).map_err(|e| format!("Invalid conversion responses: {e}"))?;
validate_responses(&responses)?;
let converted = owned.read(&owned.stamp()?)?;
if format == "ai" && !converted.starts_with(b"%PDF-") {
return Err("VectorCraft did not save a PDF-compatible AI working copy".into());
}
Ok(converted)
})();
if result.is_err() {
let _ = child.kill();
let _ = child.wait();
}
result
}
/// Build requests only from UTF-8 paths: serializing a non-UTF-8 `Path` inside `json!` panics.
#[cfg(all(test, unix))]
fn conversion_commands(input: &Path, output: &Path) -> Result<[(u8, &'static str, serde_json::Value); 2], String> {
export_commands(input, output, "svg")
}
/// The open (id 1) and export (id 2) requests that [`validate_responses`] checks; AI exports
/// preserve editing data. Non-UTF-8 paths return errors because `json!` would panic on them.
fn export_commands(input: &Path, output: &Path, format: &str) -> Result<[(u8, &'static str, serde_json::Value); 2], String> {
let input = input.to_str().ok_or("The temporary vector input path is not UTF-8")?;
let output = output.to_str().ok_or("The temporary vector output path is not UTF-8")?;
Ok([
(1, "document.open", serde_json::json!({"path": input})),
(2, "document.export", serde_json::json!({"path": output, "format": format, "preserveEditing": format == "ai"})),
])
}
/// Keep the original PSD source-pixel frame: PDF points and EPS bounds need not equal the
/// placed object's stored pixel dimensions. The nested viewport scales vector geometry once.
fn source_svg(bytes: &[u8], size: Option<(f64, f64)>) -> Result<Vec<u8>, String> {
let text = std::str::from_utf8(bytes).map_err(|_| "Converted SVG is not UTF-8")?;
photocraft_io::svg::import_svg_bounded_with_work("Artwork.svg", bytes, 96_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?;
let doc = roxmltree::Document::parse(text).map_err(|e| format!("Invalid converted SVG: {e}"))?;
let root = doc.root_element();
if root.tag_name().name() != "svg" {
return Err("VectorCraft did not return SVG".into());
}
let Some((w, h)) = size else {
return Ok(bytes.to_vec());
};
if !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 || w > 16384.0 || h > 16384.0 || w * h > 24_000_000.0 {
return Err("The original vector source dimensions exceed the editing limit".into());
}
// The nested root keeps its own width/height, so the outer viewBox must use the same CSS
// pixels: `612pt` is 816 user units at SVG's 96 dpi. Other units are rejected below.
let dimension = |key| {
let value = root.attribute(key)?;
let (number, scale) = value.strip_suffix("pt").map_or((value, 1.0), |number| (number, 4.0 / 3.0));
number.parse::<f64>().ok().map(|v| v * scale).filter(|v| v.is_finite() && *v > 0.0)
};
let (iw, ih) = dimension("width").zip(dimension("height")).ok_or("Converted SVG has no finite dimensions")?;
let inner = text.get(root.range()).ok_or("Invalid SVG root range")?;
let result =
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {iw} {ih}" preserveAspectRatio="none">{inner}</svg>"#)
.into_bytes();
if result.len() > 16 * 1024 * 1024 {
return Err("Converted SVG exceeds 16 MiB".into());
}
Ok(result)
}
/// Require successful replies for both opening the source and exporting the SVG.
fn validate_responses(text: &str) -> Result<(), String> {
if text.len() > 1024 * 1024 {
return Err("VectorCraft conversion responses exceeded the limit".into());
}
let replies: Vec<serde_json::Value> =
text.lines().map(serde_json::from_str).collect::<Result<_, _>>().map_err(|_| "Invalid VectorCraft conversion response")?;
for id in [1, 2] {
let reply = replies.iter().find(|r| r.get("id").and_then(serde_json::Value::as_u64) == Some(id)).ok_or("Missing VectorCraft conversion response")?;
if reply.get("error").is_some() || reply.get("result").and_then(|r| r.get("isError")).and_then(serde_json::Value::as_bool) != Some(false) {
return Err("VectorCraft could not open or export the embedded vector; original contents were retained".into());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn non_utf8_temporary_paths_return_errors_instead_of_panicking() {
use std::os::unix::ffi::OsStrExt;
let invalid = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/vector-\xff"));
let valid = Path::new("/tmp/Artwork.svg");
assert!(conversion_commands(invalid, valid).is_err());
assert!(conversion_commands(valid, invalid).is_err());
let commands = conversion_commands(valid, valid).unwrap();
assert_eq!(commands[0].2["path"], "/tmp/Artwork.svg");
assert_eq!(commands[1].2["format"], "svg");
assert_eq!(commands[1].2["preserveEditing"], false);
}
#[test]
fn converted_vectors_use_the_psd_source_pixel_frame() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="red"/></svg>"#;
let normalized = source_svg(svg, Some((16.0, 12.0))).unwrap();
let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &normalized, 24_000_000).unwrap();
assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12));
assert_eq!(source_svg(svg, None).unwrap(), svg);
let fractional = source_svg(svg, Some((16.3, 12.2))).unwrap();
let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &fractional, 24_000_000).unwrap();
assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12));
for size in [(f64::NAN, 1.0), (1.0, f64::INFINITY), (0.0, 1.0), (16384.0, 16384.0)] {
assert!(source_svg(svg, Some(size)).is_err());
}
assert!(source_svg(b"%PDF-1.6", None).is_err());
}
#[test]
fn point_sized_converted_vectors_fill_the_frame_without_cropping() {
// 12pt × 9pt is 16 × 12 CSS pixels: red on the left half, blue on the right half,
// with and without a root viewBox. Misreading pt as px would scale by 4/3 and crop.
for svg in [
r#"<svg xmlns="http://www.w3.org/2000/svg" width="12pt" height="9pt" viewBox="0 0 12 9"><rect width="6" height="9" fill="red"/><rect x="6" width="6" height="9" fill="blue"/></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="12pt" height="9pt"><rect width="8" height="12" fill="red"/><rect x="8" width="8" height="12" fill="blue"/></svg>"#,
] {
let normalized = source_svg(svg.as_bytes(), Some((16.0, 12.0))).unwrap();
let raster = photocraft_io::svg::rasterize_bounded(&normalized, 1.0, 24_000_000).unwrap();
assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12));
let (x0, y0) = (raster.rect.x0, raster.rect.y0);
let is = |x: i32, y: i32, channel: usize| {
let px = raster.get(x0 + x, y0 + y);
px[3] > 0.99 && px[channel] > 0.99 && px[2 - channel] < 0.01
};
for y in [0, 6, 11] {
assert!(is(0, y, 0) && is(6, y, 0), "left half must be red: {svg}");
assert!(is(9, y, 2) && is(15, y, 2), "right half must be blue and uncropped: {svg}");
}
}
assert!(source_svg(br#"<svg xmlns="http://www.w3.org/2000/svg" width="12mm" height="9mm"/>"#, Some((16.0, 12.0))).is_err());
}
/// A stand-in `vectorcraft-cli` that logs every request beside itself and answers the open
/// and export requests, writing a small SVG (or, for `ai`, a regenerated PDF) to the export path.
#[cfg(unix)]
const FAKE_CLI: &str = r#"#!/bin/sh
log="$(dirname "$0")/requests.log"
while IFS= read -r line; do
printf '%s\n' "$line" >> "$log"
case "$line" in
*document.export*)
out=$(printf '%s' "$line" | sed -n 's/.*"path":"\([^"]*\)".*/\1/p')
case "$line" in
*'"format":"ai"'*) printf '%%PDF-1.7 regenerated' > "$out" ;;
*) printf '<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>' > "$out" ;;
esac ;;
esac
done
printf '{"id":1,"result":{"isError":false}}\n{"id":2,"result":{"isError":false}}\n'
"#;
/// Illustrator or another chosen editor gets the original AI; only VectorCraft's own working
/// copy is regenerated (VectorCraft opens a foreign AI read-only).
#[cfg(unix)]
#[test]
fn ai_working_copy_is_regenerated_only_for_vectorcraft() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let editor = directory.path().join("Editor");
std::fs::write(&editor, b"#!/bin/sh\n").unwrap();
let cli = directory.path().join("vectorcraft-cli");
std::fs::write(&cli, FAKE_CLI).unwrap();
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
let original = b"%PDF-1.7\noriginal AI".to_vec();
let source = ExternalVectorSource { bytes: std::sync::Arc::new(original.clone()), extension: "ai".into(), size: None };
let (_send, stop) = std::sync::mpsc::channel();
let (owned, initial) = prepare(&editor, &source, &stop, false).unwrap();
assert_eq!(initial, original);
assert_eq!(std::fs::read(&owned.file).unwrap(), original, "the other editor opens the original AI");
let log = std::fs::read_to_string(directory.path().join("requests.log")).unwrap();
assert!(log.contains(r#""format":"svg""#), "the preview is still converted: {log}");
assert!(!log.contains(r#""format":"ai""#), "no AI re-export for another editor: {log}");
let (_owned, regenerated) = prepare(&editor, &source, &stop, true).unwrap();
assert_eq!(regenerated, b"%PDF-1.7 regenerated");
}
#[test]
fn conversion_requires_successful_open_and_export_replies() {
let good = r#"{"id":1,"result":{"isError":false}}
{"id":2,"result":{"isError":false}}"#;
assert!(validate_responses(good).is_ok());
for bad in [
"",
r#"{"id":2,"result":{"isError":false}}"#,
r#"{"id":1,"result":{"isError":true}}
{"id":2,"result":{"isError":false}}"#,
r#"{"id":1,"error":{"message":"denied"}}"#,
] {
assert!(validate_responses(bad).is_err());
}
}
}
#[cfg(test)]
mod live_vectorcraft_tests {
use super::*;
use std::sync::{Arc, mpsc};
/// Opt-in executable test uses only our own PostScript, never user artwork or Adobe assets.
#[test]
#[ignore = "set VECTORCRAFT_TEST_EDITOR to a local VectorCraft executable"]
fn real_vectorcraft_converts_eps_and_rejects_failed_open_without_blank_replacement() {
let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR");
let editor = Path::new(&editor);
let (_send, stop) = mpsc::channel();
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 8 6\n0 0 1 setrgbcolor 0 0 8 6 rectfill\nshowpage\n%%EOF\n";
let source = ExternalVectorSource { bytes: Arc::new(eps.to_vec()), extension: "eps".into(), size: Some((16.0, 12.0)) };
let (owned, initial) = prepare(editor, &source, &stop, true).unwrap();
assert_eq!(initial, *source.bytes);
assert_eq!(owned.file.extension().unwrap(), "eps");
assert_eq!(std::fs::read(&owned.file).unwrap(), *source.bytes);
let svg = render_preview(editor, &source, &stop).unwrap();
assert!(owned.file.exists());
let raster = photocraft_io::svg::rasterize_bounded(&svg, 1.0, 24_000_000).unwrap();
assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12));
let invalid = ExternalVectorSource { bytes: Arc::new(b"%PDF-1.6\nnot a PDF".to_vec()), extension: "pdf".into(), size: None };
assert!(prepare(editor, &invalid, &stop, true).is_err());
let root = owned.root.clone();
drop(owned);
assert!(!root.exists());
}
}
+29 -4
View File
@@ -2,8 +2,7 @@
//! status 0 believes the control server is running when it is not.
//!
//! These tests run the real binary as a subprocess and assert the exit code,
//! covering both failure paths in `main.rs`: control authentication and the
//! automation workspace.
//! covering control authentication, the automation workspace and listener binding.
/// A syntactically valid 64-hex-char token, so the workspace test reaches the
/// workspace check instead of failing on token validation first.
@@ -28,7 +27,7 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(crate::binary())
let mut child = std::process::Command::new(crate::binary())
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
// Isolate from the host environment so the test is reproducible.
@@ -41,12 +40,38 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.envs(vars.iter().copied())
.output()
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to run photocraft binary");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
}
if std::time::Instant::now() >= deadline {
child.kill().unwrap();
break true;
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
let output = child.wait_with_output().unwrap();
let gpu_started = dir.join("gpu-starting.json").exists();
std::fs::remove_dir_all(&dir).ok();
assert!(!timed_out, "PhotoCraft did not exit: {}", String::from_utf8_lossy(&output.stderr));
assert!(!gpu_started, "control setup errors must exit before GPU startup");
Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
#[test]
fn an_occupied_control_port_exits_with_status_2() {
let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = held.local_addr().unwrap().port().to_string();
let Run { status, stderr } = run_photocraft(&["--control", &port, "--control-token", VALID_TOKEN]);
assert_eq!(status.code(), Some(2), "occupied control port must exit 2, stderr: {stderr}");
assert!(stderr.contains(&format!("failed to bind 127.0.0.1:{port}")), "stderr: {stderr}");
}
#[test]
fn invalid_control_token_exits_with_status_2() {
// `validate_token` rejects a token that is not exactly 64 hex characters.
+3 -4
View File
@@ -17,14 +17,13 @@ Exactly three colours:
## Geometry
A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders crop 22 units off
each side so the figure reads at 16–48 px.
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders use the whole tile,
as the other Crafting Apps do.
## Provenance
The owner's original drawing, made in ArtCraft (2880 px, engraving style, keyed to the palette),
vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source PNG stays in
craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.txt`.
vectorised. License: see `LICENSE.txt`.
## Files
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 B

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 90 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M5 5L19 19M19 5L5 19" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M10.35 5V19M13.65 5V19M5 10.35H19M5 13.65H19" />
</svg>

After

Width:  |  Height:  |  Size: 411 B

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M15.31 10.14L15.33 10.26L15.33 10.38L15.30 10.52L15.23 10.65L15.14 10.77L15.01 10.87L14.85 10.96L14.67 11.00L14.46 11.01L14.25 10.98L14.04 10.89L13.83 10.75L13.65 10.56L13.51 10.32L13.41 10.04L13.37 9.72L13.40 9.38L13.52 9.03L13.71 8.70L13.99 8.39L14.35 8.13L14.79 7.94L15.29 7.85L15.83 7.86L16.39 8.00L16.95 8.27L17.46 8.68L17.91 9.22L18.25 9.89L18.46 10.67L18.50 11.52L18.35 12.42L17.98 13.33L17.40 14.19L16.59 14.96L15.57 15.58L14.37 15.99L13.03 16.15L11.58 16.01L10.11 15.54L8.68 14.71L7.38 13.52L6.29 11.99L5.50 10.14" />
</svg>

After

Width:  |  Height:  |  Size: 889 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M8.5 5V19M12 5V19M15.5 5V19M5 8.5H19M5 12H19M5 15.5H19" />
</svg>

After

Width:  |  Height:  |  Size: 421 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M9.67 5V19M14.33 5V19M5 9.67H19M5 14.33H19" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5H19V19H5Z" />
<path d="M9.67 2.8V5M14.33 2.8V5M9.67 19v2.2M14.33 19v2.2M2.8 9.67H5M2.8 14.33H5M19 9.67h2.2M19 14.33h2.2" />
<path d="M5 5L19 19M5 19L12 12" />
</svg>

After

Width:  |  Height:  |  Size: 388 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"
>
<path d="M3 3h12v6h6v12H9v-6H3z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 266 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"
>
<path d="M3 3h12v12H3zM9 9h12v12H9z" fill="currentColor" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 290 B

+15
View File
@@ -0,0 +1,15 @@
<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"
>
<rect x="3" y="3" width="12" height="12" />
<rect x="9" y="9" width="12" height="12" />
<path d="M9 9h6v6H9z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 347 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"
>
<path d="M3 3h12v6h6v12H9v-6H3z" />
</svg>

After

Width:  |  Height:  |  Size: 246 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"
>
<rect x="4" y="4" width="16" height="16" rx="1" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3h12v6H9v6H3z" fill="currentColor" />
<rect x="9" y="9" width="12" height="12" />
</svg>

After

Width:  |  Height:  |  Size: 306 B

+5
View File
@@ -8,9 +8,14 @@ tiles/<blake3>.zst
blobs/<blake3>.zst
thumb.png
composite/preview.png
composite/full.png
save.lock
```
`thumb.png` (at most 256 px on the longer side), `composite/preview.png` (at most 1024 px) and `composite/full.png` (the canvas size) are the flattened document as 8-bit RGBA PNGs, whatever the document's depth. The manifest names each one it holds, under `thumbnail`, `composite` and `full_composite`.
A save writes `composite/full.png` only when it is asked to: *Save Full-Size Composite in .pcraft Files* in Preferences › File Handling for the app's saves, `"pcraftFullComposite": true` for the engine's `file.saveACopy` and its batch and export commands, and for the automation `doc.save` and MCP `doc_save`, and `--pcraft-full-composite` for `photocraft-cli convert`, `run` and `batch`. It is off by default. `composite/full.png` embeds the ICC profile of its pixels: an RGB document's own profile, a grayscale document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable profile. A save leaves it out when the canvas is larger than 2^26 pixels, when its pixels cannot be allocated or encoded, or when a ZIP bundle would pass 4 GiB or 65,535 entries with it. A directory bundle saved without it has the file from an earlier save removed.
`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.
+13 -1
View File
@@ -1,11 +1,23 @@
# Raster formats
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868.
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
## JPEG XL
Save As, Export As and the CLI can write a lossless, single-image `.jxl` bare
codestream at 8 or 16 bits per sample. Gray, RGB and RGBA are encoded
natively. Grayscale with alpha is expanded to RGBA at the original bit depth
to preserve both grayscale and alpha samples. For 16-bit RGBA the encoder's
incorrect default 8-bit alpha header is corrected to explicitly describe
16-bit alpha, with an independent decoder oracle checking alpha values.
The bare codestream has no ICC, EXIF, XMP or DPI boxes; the export path
converts tagged RGB to sRGB and surfaces metadata losses as fidelity warnings.
JPEG XL opening remains under review in #2868.
## WebP
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
+32 -6
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -107,7 +104,9 @@ display_name = "Ran Geerts"
sync_github_name = false
[people.vangel76]
sync_github_name = true
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.FinnNk]
sync_github_name = true
@@ -205,6 +204,9 @@ sync_github_name = true
[people.fjamesprice]
sync_github_name = true
[people.mockydevs]
real_name = "Don Omondi"
[people.ha1des]
sync_github_name = true
@@ -265,6 +267,11 @@ sync_github_name = false
[people.drunkeninja42]
real_name = "Kushagra Sharma"
[people.hidoba]
real_name = "Vlad Grankovsky"
display_name = "Vlad Grankovsky"
sync_github_name = false
[people.WebWork-rrs]
display_name = "Ratul Paul"
@@ -282,3 +289,22 @@ sync_github_name = false
[people.jasallen]
real_name = "Jason DeBoever"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Yuki-init]
display_name = "DC@yuki.himejima"
sync_github_name = false
[people.sn-o-w]
real_name = "Cristian Silaghi"
sync_github_name = false
[people.regexer]
sync_github_name = true
[people.SimoneRecchia]
real_name = "Simone Recchia"
display_name = "Simone Recchia"
sync_github_name = false
+1 -4
View File
@@ -23,10 +23,7 @@ impl Stream {
/// 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,
}
self.field(id, tag)?.array_len()
}
/// A child object (shared, linked or inline); `None` for null or missing.
+9
View File
@@ -20,6 +20,11 @@ const MAX_ENTRIES: usize = 1 << 22;
/// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry
/// and as its map key), and tables can point many names at the same bytes.
const MAX_NAME_BYTES: usize = 32 << 20;
/// File and directory records parsed across every allocation table. Each table walks its own
/// records, and tables may start inside one another and share a long suffix, so the per-table
/// counts do not bound the work: a few thousand overlapping tables could reparse one large run
/// of records each. Real files hold a few hundred saves of a few thousand entries.
const MAX_TABLE_RECORDS: usize = 1 << 25;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
@@ -175,6 +180,7 @@ impl<'a> Archive<'a> {
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
let mut records_left = MAX_TABLE_RECORDS;
while next != 0 {
if !seen.insert(next) {
return Err(Error::Malformed("allocation tables form a cycle"));
@@ -199,6 +205,9 @@ impl<'a> Archive<'a> {
c.take(8)?;
let dirs = c.u16()?;
c.take(1)?;
// Charge the declared records before walking them.
let declared = usize::try_from(files).unwrap_or(usize::MAX).saturating_add(usize::from(dirs));
records_left = records_left.checked_sub(declared).ok_or(Error::Limit("too many allocation records"))?;
for _ in 0..files {
let id = c.u32()?;
let flag = c.u8()?;
+25 -15
View File
@@ -153,8 +153,8 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
let fields: Vec<&Value> = s.positional(c).collect();
// The corner arrays are scanned for every occurrence of a shared curve, so charge
// them like records before they are walked.
if let Some(Value::Array(idx)) = fields.get(1)
&& let Err(reason) = r.geometry_budget.records(idx.len())
if let Some(len) = fields.get(1).and_then(|v| v.array_len())
&& let Err(reason) = r.geometry_budget.records(len)
{
r.warn(reason);
return None;
@@ -170,17 +170,25 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
Value::Bool(b) => *b,
_ => return None,
};
let Value::Array(records) = fields.next()? else { return None };
seen = seen.checked_add(records.len())?;
let records = fields.next()?;
let len = records.array_len()?;
seen = seen.checked_add(len)?;
if seen > MAX_NODES {
r.warn("curves with more than four million nodes");
return None;
}
if let Err(reason) = r.geometry_budget.records(records.len()) {
// Charge the subpath itself as well as its records: an empty subpath adds no records
// or points, but a shared curve full of them is still walked for every occurrence.
if let Err(reason) = r.geometry_budget.records(len.saturating_add(1)) {
r.warn(reason);
return None;
}
let anchors = anchors(records)?;
let anchors = match records {
Value::Array(a) => anchors(a.iter().map(record)),
Value::Records { size: 18, data } => anchors(data.as_chunks::<18>().0.iter().map(record18)),
// Records of another width are no curve nodes.
_ => anchors(std::iter::repeat_n(None, len)),
}?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
path.subpaths.push(sp);
@@ -202,22 +210,24 @@ struct Anchor {
fn record(v: &Value) -> Option<(Point, u8)> {
let Value::Bytes(b) = v else { return None };
if b.len() != 18 {
return None;
}
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
record18(b.as_slice().try_into().ok()?)
}
/// One 18-byte curve node: f64 x, f64 y, u8 kind, u8 role.
fn record18(b: &[u8; 18]) -> Option<(Point, u8)> {
let x = f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]);
let y = f64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]);
if !x.is_finite() || !y.is_finite() {
return None;
}
Some((Point { x, y }, *b.get(17)?))
Some((Point { x, y }, b[17]))
}
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
fn anchors(records: impl ExactSizeIterator<Item = Option<(Point, u8)>>) -> Option<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
for (i, v) in records.enumerate() {
let (p, role) = v?;
match role {
0 => {
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
+3
View File
@@ -300,6 +300,9 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
// The parsed stream owns copies of everything it needs: do not hold the inflated bytes
// (hundreds of MB for a large vector document) while the model is built.
drop(doc);
Reader::new(&s, &mut archive, limits).document()
}
+90 -23
View File
@@ -125,15 +125,26 @@ pub struct Stroke {
/// Gradient stops per gradient.
const MAX_STOPS: usize = 1024;
/// Dash and gap lengths per stroke. Affinity's editor offers three dash/gap pairs; a longer
/// pattern would be copied into every shape that shares its style.
const MAX_DASHES: usize = 64;
fn floats(v: &Value) -> Option<Vec<f64>> {
/// The first `n` components of a float vector or packed `f32` record. Colours and gradient
/// positions use a few leading components; a shared colour with a huge record is decoded for
/// every occurrence, so the rest is neither converted nor copied.
fn floats(v: &Value, n: usize) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
Value::Floats(f) => Some(f.iter().take(n).copied().collect()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b, n)),
_ => None,
}
}
/// Up to `n` little-endian `f32` samples widened to `f64`.
fn f32_samples(b: &[u8], n: usize) -> Vec<f64> {
b.as_chunks::<4>().0.iter().take(n).map(|c| f64::from(f32::from_le_bytes(*c))).collect()
}
/// 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
@@ -149,26 +160,27 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
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>> {
let v = floats(s.field(id, b"_col")?)?;
// `n` is how many components the model reads; only those are decoded and checked.
let col = |n: usize| -> Option<Vec<f64>> {
let v = floats(s.field(id, b"_col")?, n)?;
v.iter().all(|f| f.is_finite()).then_some(v)
};
Some(match &class.0.to_be_bytes() {
b"RGBA" => {
let v = col()?;
let v = col(4)?;
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"HSLA" => {
let v = col()?;
let v = col(4)?;
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"CMYK" => {
let v = col()?;
let v = col(5)?;
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
}
b"GRAY" => {
let v = col()?;
let v = col(2)?;
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
}
b"LABA" => {
@@ -240,7 +252,11 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
}
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
// A position is (offset, bias): later components are not decoded.
Some(Value::Array(a)) => a.iter().filter_map(|v| floats(v, 2)).take(MAX_STOPS).collect(),
Some(Value::Records { size, data }) if *size % 4 == 0 && *size > 0 => {
data.chunks_exact(usize::from(*size)).map(|b| f32_samples(b, 2)).take(MAX_STOPS).collect()
}
_ => Vec::new(),
};
let mut stops = Vec::new();
@@ -266,23 +282,52 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
}
}
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
let items = r.s.objs(id, list);
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
items.get(i).or_else(|| items.first()).copied()
/// The active entry of a paint list (the `index`-th object, else the first) and how many
/// objects the list holds. Every raw slot, null ones included, is charged before the list is
/// walked: a shared layer's lists are read again for each occurrence. A list beyond the
/// document's work budget reads as empty.
fn current(r: &mut Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> (Option<ObjId>, usize) {
let s = r.s;
let Some(Value::Array(items)) = s.field(id, list) else { return (None, 0) };
if r.charge_slots(items.len()).is_err() {
r.warn("fill and stroke lists beyond the document work budget (left empty)");
return (None, 0);
}
let want = s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
let (mut first, mut chosen, mut count) = (None, None, 0usize);
for o in items.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }) {
first = first.or(Some(o));
if count == want {
chosen = Some(o);
}
count += 1;
}
(chosen.or(first), count)
}
/// The fills of `list` (artboard backgrounds use `BFFl`).
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
fills_counted(r, id, local, list).0
}
/// [`fills`], and how many entries `list` holds.
fn fills_counted(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> (Vec<Paint>, usize) {
let (d, count) = current(r, id, list, b"BFCr");
let d = d.or_else(|| r.s.obj(id, b"BFil"));
(d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect(), count)
}
/// A node's fill and stroke. `local` maps the node's space to document pixels.
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
let s = r.s;
let fills = fills(r, id, local, b"BFFl");
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
let (fills, fill_count) = fills_counted(r, id, local, b"BFFl");
let (pen, style, pen_count) = if s.field(id, b"LIFl").is_some() {
let (pen, pens) = current(r, id, b"LIFl", b"LICr");
(pen, current(r, id, b"LILn", b"LICr").0, pens)
} else {
(s.obj(id, b"PFil"), s.obj(id, b"LSty"), 0)
};
if fill_count > 1 || pen_count > 1 {
r.warn("several fills or strokes on one object (only the active one is imported)");
}
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
@@ -291,11 +336,6 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint
if s.obj(id, b"Trns").is_some() {
r.warn("transparency gradients (imported without them)");
}
let (pen, style) = if s.field(id, b"LIFl").is_some() {
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
} else {
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
};
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
let stroke = match (pen, style) {
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
@@ -333,6 +373,17 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
_ => Align::Center,
};
let dash = match s.field(lsty, b"Patn") {
// Each occurrence of a shared style keeps its own scaled copy: refuse long patterns
// before scanning or copying them.
Some(Value::Array(a)) if a.len() > MAX_DASHES => {
r.warn("dash patterns longer than 64 entries (imported as solid strokes)");
None
}
// The copies are retained across the document: charge them like other shared slots.
Some(Value::Array(a)) if r.charge_slots(a.len()).is_err() => {
r.warn("dash patterns beyond the document work budget (imported as solid strokes)");
None
}
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
@@ -348,3 +399,19 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
}
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_components_a_schema_reads_are_decoded() {
// A 64 KiB record decodes to the four components asked for, not sixteen thousand.
let record = Value::Bytes(1.0f32.to_le_bytes().repeat(16_383));
assert_eq!(floats(&record, 4), Some(vec![1.0; 4]));
assert_eq!(floats(&Value::Floats(vec![0.5; 6]), 2), Some(vec![0.5; 2]));
// Fewer components than asked for are returned as they are, for the caller to refuse.
assert_eq!(floats(&Value::Floats(vec![0.5; 3]), 5), Some(vec![0.5; 3]));
assert_eq!(floats(&Value::Bytes(vec![0; 3]), 4), None);
}
}
+13 -6
View File
@@ -187,12 +187,12 @@ fn plane(
Some(Value::Array(a)) => a,
_ => &[],
};
let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) {
Some(Value::Array(a)) => a.as_slice(),
// Tile references in order. Null slots are skipped, but every slot walked (null or not) is
// charged below, so a shared bitmap's long null prefix is not rescanned for free.
let mut tiles: &[Value] = match s.field(bitmap, &tag(b"Idx", c)) {
Some(Value::Array(a)) => a,
_ => &[],
}
.iter()
.filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None });
};
// The scan below runs for every occurrence of a shared bitmap, whatever it produces.
r.charge_slots(states.len().min(tw.saturating_mul(th)))?;
let row = (w as usize) * bps;
@@ -206,7 +206,14 @@ fn plane(
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) };
let Some(at) = tiles.iter().position(|v| matches!(v, Value::Obj(Some(_)))) else {
r.charge_slots(tiles.len())?;
return Err(Error::Malformed("missing pixel tile"));
};
r.charge_slots(at + 1)?;
let Some(Value::Obj(Some(blck))) = tiles.get(at) else { return Err(Error::Malformed("missing pixel tile")) };
let blck = *blck;
tiles = tiles.get(at + 1..).unwrap_or_default();
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
+9 -6
View File
@@ -12,9 +12,10 @@ const KAPPA: f64 = 0.552_284_749_830_793_4;
const MAX_SIDES: i64 = 10_000;
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
/// Indexed: a shared shape is read again for every occurrence, and rescanning a large field
/// list each time multiplied into work far beyond the file's size.
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
let t = Tag::of(tag);
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
r.s.object(id)?.get_last(Tag::of(tag))
}
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
@@ -51,12 +52,14 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let types = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
// Only the four corners are read: a longer array is not scanned or copied, which
// would otherwise repeat for every occurrence of a shared shape.
let types: Vec<Option<u16>> = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
_ => Vec::new(),
};
let radii = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
let radii: Vec<f64> = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
_ => Vec::new(),
};
// Corners follow the first one (top left) unless they were unlocked; then each has
+77 -25
View File
@@ -53,6 +53,23 @@ pub enum Value {
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
/// An array of fixed-size records (`0x2c`, curve nodes) held in one buffer: `size` bytes each.
/// Counts as one value per record against [`MAX_VALUES`], like the array of `Bytes` it replaces.
Records {
size: u16,
data: Vec<u8>,
},
}
impl Value {
/// Slot count of an array field (including null slots); `None` for a scalar.
pub fn array_len(&self) -> Option<usize> {
match self {
Value::Array(a) => Some(a.len()),
Value::Records { size, data } => Some(data.len() / usize::from(*size).max(1)),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -73,39 +90,72 @@ 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>,
/// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence,
/// index of its last occurrence) sorted by tag, so `get` and `get_last` are a binary search.
/// 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. Smaller objects have no index (and no per-object hash map to build):
/// scanning a handful of fields is cheaper.
index: Vec<(Tag, u32, u32)>,
}
/// Objects with at most this many fields are scanned instead of indexed.
const SCAN: usize = 12;
fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32, u32)> {
if fields.len() <= SCAN {
return Vec::new();
}
// Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) puts
// each tag's occurrences together in stream order: the first and last of each run keep
// first-match semantics for `get` and last-match semantics for `get_last`.
let mut index: Vec<(Tag, u32, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i, i)).collect();
index.sort_unstable();
index.dedup_by(|later, kept| {
let same = later.0 == kept.0;
if same {
kept.2 = later.2;
}
same
});
index
}
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);
}
}
let index = build_index(&fields);
Object { class, kind, chain, fields, index }
}
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.index.get(&tag).map(|&i| &self.fields[i].1)
if tag.0 == 0 {
return None;
}
if self.fields.len() <= SCAN {
return self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v);
}
let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?;
self.index.get(at).and_then(|&(_, i, _)| self.fields.get(i as usize)).map(|(_, v)| v)
}
/// The last field with `tag`, for objects that may repeat a tag (a shape changed from one
/// kind to another keeps a stale first block). Indexed like [`Object::get`], so repeated
/// lookups on a large shared object do not rescan its fields.
pub fn get_last(&self, tag: Tag) -> Option<&Value> {
if tag.0 == 0 {
return None;
}
if self.fields.len() <= SCAN {
return self.fields.iter().rev().find(|(t, _)| *t == tag).map(|(_, v)| v);
}
let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?;
self.index.get(at).and_then(|&(_, _, i)| self.fields.get(i as usize)).map(|(_, v)| v)
}
/// 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.index = build_index(&fields);
self.fields = fields;
}
}
@@ -336,11 +386,13 @@ impl Parser<'_> {
if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
}
Ok(Value::Array(v))
// Still one value per record against the budget, but a single allocation.
self.spend(n)?;
let bytes = self.c.take(n * size)?;
let mut data = Vec::new();
data.try_reserve_exact(bytes.len()).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
data.extend_from_slice(bytes);
Ok(Value::Records { size: size as u16, data })
}
0x32 => {
let n = self.count(1)?;
+101
View File
@@ -297,6 +297,42 @@ mod archive {
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
}
#[test]
fn allocation_records_are_charged_before_they_are_walked() {
let d = doc();
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
// The table's file count sits after its tag, next, date and 24 reserved bytes.
let files = fat + 4 + 8 + 8 + 24;
file[files..files + 4].copy_from_slice(&u32::MAX.to_le_bytes());
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records"));
// A count within the budget still reaches the records (and here fails on missing data).
file[files..files + 4].copy_from_slice(&1_000_000u32.to_le_bytes());
assert!(matches!(Archive::open(&file, Limits::default()), Err(Error::Malformed(_))));
}
#[test]
fn allocation_records_are_charged_across_every_table() {
let d = doc();
let mut file = synth::container(&[("doc.dat", &d, Method::Stored), ("other.dat", &d, Method::Stored)], None);
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
// Chain a copy of the (valid, two-record) table after it; the copy is the last table.
let second = file.len();
let table = file[fat..].to_vec();
file.extend(&table);
file[fat + 4..fat + 12].copy_from_slice(&(second as u64).to_le_bytes());
let files = second + 4 + 8 + 8 + 24;
// Each count alone is within the archive budget of 2^25; together they exceed it by one.
file[files..files + 4].copy_from_slice(&((1u32 << 25) - 1).to_le_bytes());
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records"));
// Exactly the budget is allowed: the second table is walked (and fails on missing data).
file[files..files + 4].copy_from_slice(&((1u32 << 25) - 2).to_le_bytes());
assert!(matches!(Archive::open(&file, Limits::default()), Err(Error::Malformed(_))));
// Directory records count too: one more (after the file count and 8 bytes) is over.
file[files + 12..files + 14].copy_from_slice(&1u16.to_le_bytes());
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records"));
}
#[test]
fn hostile_streams_fail_cleanly() {
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
@@ -416,6 +452,20 @@ mod shapes {
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap()
}
#[test]
fn empty_subpaths_of_a_shared_curve_are_charged_for_every_occurrence() {
// One curve of 100k empty subpaths, placed 100 times: no records and no points, but
// ten million subpaths walked unless each one is charged.
let mut data = vec![F::U8(0), F::U32(100_000)];
for _ in 0..100_000 {
data.extend([F::Bool(false), F::Records(18, Vec::new())]);
}
let curve = F::Def(5, vec![tag(b"PCrv")], vec![(tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(data))]))]);
let group = F::Def(6, vec![tag(b"Grup")], vec![(tag(b"Chld"), F::Shared((0..100).map(|_| F::Ref(5)).collect()))]);
let d = read(vec![curve, group], 6);
assert!(d.warnings.iter().any(|w| w.contains("curve geometry budget exhausted")), "{:?}", d.warnings);
}
fn path(document: &Document) -> &Path {
match &document.spreads[0].nodes[0].kind {
Kind::Shape { path, .. } => path,
@@ -757,3 +807,54 @@ mod spot_colours {
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
}
/// Object lookup and record arrays against the structures they replaced.
mod stream_storage {
use crate::stream::{self, Kind, Object, Tag, Value};
use crate::synth::{self, F, tag};
/// The per-object map `Object::get` used before: first occurrence of each real tag.
fn reference(fields: &[(Tag, Value)], t: Tag) -> Option<&Value> {
let mut index = std::collections::HashMap::new();
for (i, (ft, _)) in fields.iter().enumerate() {
if ft.0 != 0 {
index.entry(*ft).or_insert(i);
}
}
index.get(&t).map(|&i| &fields[i].1)
}
#[test]
fn lookup_keeps_first_match_for_every_object_size() {
for n in 0..60u32 {
// Tags repeat (n % 7 distinct tags) and every fifth field is positional (tag 0).
let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect();
let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone());
for t in 0..120 {
assert_eq!(object.get(Tag(t)), reference(&fields, Tag(t)), "{n} fields, tag {t}");
}
}
}
#[test]
fn last_lookup_keeps_last_match_for_every_object_size() {
for n in 0..60u32 {
let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect();
let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone());
for t in 0..120 {
let expected = if t == 0 { None } else { fields.iter().rev().find(|(k, _)| *k == Tag(t)).map(|(_, v)| v) };
assert_eq!(object.get_last(Tag(t)), expected, "{n} fields, tag {t}");
}
}
}
#[test]
fn a_fixed_size_array_is_one_buffer_and_one_value_per_record() {
let records: Vec<u8> = (0..18 * 5).map(|i| i as u8).collect();
let bytes = synth::stream(&[(tag(b"Nods"), F::Records(18, records.chunks(18).map(<[u8]>::to_vec).collect()))]);
let s = stream::parse(&bytes).unwrap();
let root = s.object(s.root).unwrap();
assert_eq!(root.get(Tag::of(b"Nods")), Some(&Value::Records { size: 18, data: records }));
assert_eq!(root.get(Tag::of(b"Nods")).and_then(Value::array_len), Some(5));
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
_ => bounds.x0,
};
if !artistic {
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
if s.objs(frame, b"ColW").len() > 1 || s.array_len(frame, b"ColW").is_some_and(|n| n > 1) {
r.warn("text frames with several columns (imported as one column)");
}
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
+5
View File
@@ -23,3 +23,8 @@ rayon = { workspace = true }
[dev-dependencies]
photocraft-testkit = { workspace = true }
serde_json = { workspace = true }
criterion = { workspace = true }
[[bench]]
name = "blur"
harness = false
+33
View File
@@ -0,0 +1,33 @@
//! `cargo bench -p photocraft-algo`
use criterion::{Criterion, criterion_group, criterion_main};
use photocraft_algo::{FilterParams, apply};
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn textured(n: u32) -> Surface {
let r = Rect::new(0, 0, n as i32, n as i32);
let mut s = Surface::new(PixelFormat::RGBA8);
let bytes: Vec<u8> = (0..n).flat_map(|y| (0..n).flat_map(move |x| [(x ^ y) as u8, (x / 3) as u8, (y * 7) as u8, 255])).collect();
s.write_interleaved(r, &bytes);
s
}
/// Gaussian Blur's direct-kernel path (radius up to 4; larger radii use box passes), including
/// the variance-matched kernels below radius 1 (#8544).
fn gaussian_direct(c: &mut Criterion) {
let n = 2048;
let s = textured(n);
let area = Rect::new(0, 0, n as i32, n as i32);
for radius in [0.3f32, 0.8, 3.0] {
let p = FilterParams::GaussianBlur { radius };
c.bench_function(&format!("gaussian_blur_{radius}px_2048"), |b| b.iter(|| apply(&s, &p, area, area, None)));
}
}
criterion_group! {
name = benches;
config = Criterion::default().sample_size(10);
targets = gaussian_direct
}
criterion_main!(benches);
+18
View File
@@ -122,6 +122,24 @@ fn main() {
};
wideangle::apply(&s, r, &p, Interp::Bicubic)
});
// The mesh solve alone, on the dialog's 800 x 533 proxy.
let proxy = Rect::new(0, 0, 800, 533);
let solve_case = |constraints: Vec<Constraint>| {
let p = WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints, ..Default::default() };
wideangle::solve(&p, proxy)
};
time("wide angle solve: 800x533 proxy, H + V", &only, || {
solve_case(vec![
Constraint { a: [60.0, 80.0], b: [740.0, 80.0], orientation: Orientation::Horizontal },
Constraint { a: [100.0, 60.0], b: [100.0, 470.0], orientation: Orientation::Vertical },
])
});
time("wide angle solve: 800x533 proxy, 2 free", &only, || {
solve_case(vec![
Constraint { a: [60.0, 80.0], b: [740.0, 95.0], orientation: Orientation::Free },
Constraint { a: [100.0, 60.0], b: [110.0, 470.0], orientation: Orientation::Free },
])
});
time("hdr: merge 3 exposures + local adaptation", &only, || {
let shots: Vec<Vec<[f32; 4]>> =
[0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect();
+104
View File
@@ -0,0 +1,104 @@
//! Stack Mode Entropy timings and exact output hashes, including lookup preparation.
//! Run the same example on both revisions: `cargo run --release -p photocraft-algo --example
//! bench_stack_entropy -- [--quick] [--reps N] [--threads N] [--json path]`.
//! Full cases use 24/36 MP; quick cases use 1/16 as many pixels. The deep-stack fallback is
//! deliberately small. Input generation, output hashing and deallocation are outside timings.
use std::path::PathBuf;
use std::time::Instant;
use photocraft_algo::stack::{Stat, combine};
use photocraft_testkit::perf::{self, Summary};
use serde_json::json;
struct Options {
quick: bool,
reps: usize,
threads: Option<usize>,
json: Option<PathBuf>,
}
fn options() -> Result<Options, String> {
let mut o = Options { quick: false, reps: 5, threads: None, json: None };
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--quick" => o.quick = true,
"--reps" => o.reps = args.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "invalid --reps")?,
"--threads" => o.threads = Some(args.next().ok_or("--threads needs an integer")?.parse().map_err(|_| "invalid --threads")?),
"--json" => o.json = Some(args.next().ok_or("--json needs a path")?.into()),
_ => return Err(format!("unknown option: {arg}")),
}
}
if !(1..=25).contains(&o.reps) || o.threads.is_some_and(|n| !(1..=64).contains(&n)) {
return Err("expected 1..=25 repetitions and 1..=64 threads".into());
}
Ok(o)
}
fn benchmark(o: &Options) -> Result<(), String> {
let mut rows = Vec::new();
let cases = [
("3 layers opaque 24 MP", 3, 24_000_000, false),
("7 layers varying alpha 24 MP", 7, 24_000_000, true),
("3 layers opaque 36 MP", 3, 36_000_000, false),
("257 layers varying alpha", 257, 8192, true),
];
for (name, count, pixels, masked) in cases {
let pixels = if o.quick && pixels > 8192 { pixels / 16 } else { pixels };
let layers: Vec<Vec<[f32; 4]>> = (0..count)
.map(|k| {
(0..pixels)
.map(|i| {
[
((i * 31 + k * 17) % 256) as f32 / 255.0,
((i / 7 + k * 29) % 32) as f32 / 31.0,
((i + k * 13) % 1024) as f32 / 255.0 - 0.5,
if masked && k > i % (count + 1) { 0.0 } else { 1.0 },
]
})
.collect()
})
.collect();
// Warm the worker pool and input pages before collecting samples.
std::hint::black_box(combine(&layers, Stat::Entropy));
let mut samples = Vec::with_capacity(o.reps);
let mut hash = 0u64;
for rep in 0..o.reps {
let start = Instant::now();
let out = std::hint::black_box(combine(&layers, Stat::Entropy));
samples.push(start.elapsed().as_secs_f64() * 1000.0);
if rep == 0 {
hash = out.iter().flatten().fold(0xcbf29ce484222325, |h, v| (h ^ u64::from(v.to_bits())).wrapping_mul(0x100000001b3));
}
}
let p50 = Summary::of(&samples).map_or(0.0, |s| s.p50);
println!("{name}: {pixels} pixels, {p50:.3} ms p50, hash {hash:016x}, samples_ms={samples:?}");
let mut row = perf::row(name, &samples, perf::process_peak_rss_bytes(), None);
row["output_hash"] = json!(format!("{hash:016x}"));
row["pixels"] = json!(pixels);
rows.push(row);
}
if let Some(path) = &o.json {
let report = perf::report("bench_stack_entropy", json!({"quick": o.quick, "threads": o.threads}), rows, None);
std::fs::write(path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?;
}
Ok(())
}
fn run() -> Result<(), String> {
let o = options()?;
#[cfg(not(target_arch = "wasm32"))]
if let Some(threads) = o.threads {
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().map_err(|e| e.to_string())?;
return pool.install(|| benchmark(&o));
}
benchmark(&o)
}
fn main() {
if let Err(e) = run() {
eprintln!("{e}");
std::process::exit(1);
}
}
+13
View File
@@ -236,6 +236,19 @@ impl GalleryEffect {
}
}
/// A copy whose stored numeric values are held to their declared ranges (a non-finite value
/// becomes the default). A project file stores `values` as plain numbers, so unlike
/// [`Self::set`] nothing has clamped them; geometry sized from them (reach) needs it.
pub(crate) fn clamped(&self) -> GalleryEffect {
let mut out = self.clone();
for p in self.filter.params() {
if let (GalleryParamKind::Range { min, max, default }, Some(v)) = (&p.kind, out.values.get_mut(p.key)) {
*v = if v.is_finite() { v.clamp(*min, *max) } else { *default };
}
}
out
}
/// A bool value.
pub fn flag(&self, key: &str) -> bool {
self.get(key) != 0.0
+181 -16
View File
@@ -17,6 +17,7 @@ use photocraft_geom::Rect;
use crate::Ctx;
use crate::artistic::GalleryEffect;
use crate::artistic::GalleryFilter as F;
use crate::cutout;
use crate::fxutil::{box_blur_n, cell_point, gauss_blur_n, gauss_box_radii, rgba, set_rgba};
use crate::image::Image;
use crate::noise::hash01;
@@ -406,10 +407,13 @@ fn highlights(p: Px, l: f32, area: f32, intensity: f32) -> Px {
/// Pixels an effect reads around an output pixel.
pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
// Stored values come from a project file unclamped; keep the arithmetic below inside i32.
let e = &e.clamped();
let g = |k: &str| e.get(k);
match e.filter {
F::ColoredPencil => 2,
F::Cutout => gs(cutout_sigma(e)),
// Traced over the whole image before tiling (see [`Prepared`]).
F::Cutout => 0,
F::DryBrush | F::Fresco => kuwahara_reach(1 + g("brushSize") as usize) + gs(1.0) + 2,
F::FilmGrain | F::DiffuseGlow | F::ChalkCharcoal | F::Reticulation | F::Grain | F::MosaicTiles | F::Texturizer => 0,
F::RoughPastels => gs(0.6 + 1.2) + ((2.0 + g("strokeLength")) / 2.0) as i32 + 3,
@@ -451,8 +455,8 @@ 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"))
fn cutout_settings(e: &GalleryEffect) -> cutout::Settings {
cutout::Settings { levels: e.get("numberOfLevels").round().clamp(2.0, 8.0) as usize, simplicity: e.get("edgeSimplicity"), fidelity: 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 {
@@ -526,7 +530,7 @@ fn glass_cell(e: &GalleryEffect) -> f32 {
/// Total reach of a stack of effects.
pub(crate) fn reach(effects: &[GalleryEffect]) -> i32 {
effects.iter().map(effect_reach).sum()
effects.iter().map(effect_reach).fold(0, i32::saturating_add)
}
/// Runs a stack of effects over `out` (the source covers `out` grown by [`reach`]).
@@ -538,9 +542,60 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
return src.crop(out);
}
let mut win = Win { r, w, h, c: src.data.chunks_exact(n).map(|px| rgb(rgba(ctx, px))).collect() };
for e in effects {
apply(e, &mut win, ctx);
// The reach that sized `src` comes from the clamped values, so the pixels must use them too.
for e in effects.iter().map(GalleryEffect::clamped) {
apply(&e, &mut win, ctx);
}
finish(&win, src, out, ctx)
}
/// Whether a stack needs the whole image at once (Cutout traces it).
pub(crate) fn is_global(effects: &[GalleryEffect]) -> bool {
effects.iter().any(|e| e.filter == F::Cutout)
}
/// A stack with a Cutout, prepared once for all tiles: the effects up to the last Cutout run
/// over the whole image and it is traced; each tile then only fills in the traced shapes and
/// runs the effects after it.
pub(crate) struct Prepared {
traced: cutout::Traced,
rest: Vec<GalleryEffect>,
}
impl Prepared {
/// `None` for a stack that has no Cutout (it runs tile by tile as usual).
pub(crate) fn new(effects: &[GalleryEffect], src: &Image, ctx: &Ctx) -> Option<Prepared> {
// Held to the declared ranges once here (see `run`), so the traced shapes and the tiles agree.
let effects = &effects.iter().map(GalleryEffect::clamped).collect::<Vec<_>>();
let last = effects.iter().rposition(|e| e.filter == F::Cutout)?;
let r = src.rect;
let (w, h) = (r.width() as usize, r.height() as usize);
let mut win = Win { r, w, h, c: src.data.chunks_exact(src.ch).map(|px| rgb(rgba(ctx, px))).collect() };
for e in effects.get(..last).unwrap_or_default() {
apply(e, &mut win, ctx);
}
let traced = cutout::trace(&win.c, r, &cutout_settings(effects.get(last)?));
Some(Prepared { traced, rest: effects.get(last + 1..).unwrap_or_default().to_vec() })
}
/// The stack's result over `out` (`src` is the image it was prepared from).
pub(crate) fn run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let r = out.inflate(reach(&self.rest)).intersect(&src.rect);
let (w, h) = (r.width() as usize, r.height() as usize);
if w == 0 || h == 0 {
return src.crop(out);
}
let mut win = Win { r, w, h, c: self.traced.render(r) };
for e in &self.rest {
apply(e, &mut win, ctx);
}
finish(&win, src, out, ctx)
}
}
/// `out` of the source with its colour from `win` (alpha kept).
fn finish(win: &Win, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let n = src.ch;
let mut res = src.crop(out);
let ow = out.width() as usize;
for (i, px) in res.chunks_exact_mut(n).enumerate() {
@@ -585,16 +640,7 @@ fn apply_artistic(e: &GalleryEffect, win: &mut Win) -> bool {
win.c[i] = mix(paper, map(win.c[i], |v| v * 0.88), cov);
}
}
F::Cutout => {
blur3(&mut win.c, w, h, cutout_sigma(e));
let levels = g("numberOfLevels").round();
for p in &mut win.c {
// Flat tones per luminance level, keeping each region's hue.
let l = lum(p).max(1e-4);
let k = posterize(l, levels) / l;
*p = map(*p, |v| posterize(clamp01(v * k), levels * 2.0));
}
}
F::Cutout => win.c = cutout::trace(&win.c, win.r, &cutout_settings(e)).render(win.r),
F::DryBrush | F::Fresco => {
let fresco = e.filter == F::Fresco;
kuwahara(win, 1 + g("brushSize") as usize);
@@ -1344,6 +1390,8 @@ mod tests {
let fp = FilterParams::FilterGallery { effects: vec![e.clone()] };
apply_tiled(&s, &fp, b, b, None, tile, Some(b)).read_region(b)
};
// Cutout has its own test below: its tone bands are of the channel mean, which this
// pattern keeps nearly constant.
for f in [G::RoughPastels, G::AngledStrokes] {
let base = run(&GalleryEffect::new(f), 256);
for prm in f.params() {
@@ -1361,6 +1409,123 @@ mod tests {
}
}
#[test]
fn cutout_responds_to_every_slider() {
// A radial gradient (curved band edges, tones to quantize) with a 10 px square in it.
let rows: Vec<Vec<f32>> = (0..96)
.map(|y| {
(0..96)
.map(|x| {
if (70..80).contains(&x) && (14..24).contains(&y) {
return 0.05;
}
let d = (((x as f32 - 40.0).powi(2) + (y as f32 - 52.0).powi(2)).sqrt() / 60.0).min(1.0);
1.0 - d
})
.collect()
})
.collect();
let base = cutout_on(&rows, &[]);
for (key, v) in
[("numberOfLevels", 2.0), ("numberOfLevels", 8.0), ("edgeSimplicity", 0.0), ("edgeSimplicity", 10.0), ("edgeFidelity", 1.0), ("edgeFidelity", 3.0)]
{
assert_ne!(cutout_on(&rows, &[(key, v)]), base, "cutout {key} = {v} changed nothing");
}
}
/// Runs one Cutout over a grey image given as rows of values; returns the red channel.
fn cutout_on(rows: &[Vec<f32>], set: &[(&str, f32)]) -> Vec<Vec<f32>> {
let (w, h) = (rows[0].len() as i32, rows.len() as i32);
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for (y, row) in rows.iter().enumerate() {
for (x, v) in row.iter().enumerate() {
s.write_pixel(x as i32, y as i32, &[*v, *v, *v, 1.0]);
}
}
let mut e = GalleryEffect::new(crate::GalleryFilter::Cutout);
for (k, v) in set {
e.set(k, *v);
}
let b = Rect::new(0, 0, w, h);
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
out.chunks(4 * w as usize).map(|r| r.chunks(4).map(|p| p[0]).collect()).collect()
}
#[test]
fn cutout_makes_flat_shapes_with_clean_edges() {
// It used to posterize every pixel after a blur: noise survived as speckle, edges were
// ragged and smooth gradients banded instead of becoming flat paper.
let noisy: Vec<Vec<f32>> = (0..32).map(|y| (0..64).map(|x| if x < 32 { 0.2 } else { 0.8 } + (hash01(x, y, 0, 7) - 0.5) * 0.16).collect()).collect();
let out = cutout_on(&noisy, &[]);
for (x0, x1) in [(2, 28), (36, 62)] {
let vals: Vec<f32> = (2..30).flat_map(|y| out[y][x0..x1].to_vec()).collect();
let spread = vals.iter().fold(f32::MIN, |a, &v| a.max(v)) - vals.iter().fold(f32::MAX, |a, &v| a.min(v));
assert!(spread < 1e-4, "a noisy area should be one flat tone, spread {spread}");
}
// The cut is an anti-aliased pixel or two, not a blur.
for y in 0..32 {
let (lo, hi) = (out[y][16], out[y][48]);
let soft = out[y].iter().filter(|&&v| (v - lo).abs() > 0.01 && (v - hi).abs() > 0.01).count();
assert!(soft <= 2, "row {y}: {soft} edge pixels");
}
// A grey ramp becomes `numberOfLevels` equal-width bands, each its own mean tone, as in the
// reference application (0–255 over 256 px at 4 levels: steps at 64, 128 and 192, the
// upper bands 96, 160 and 224).
let ramp: Vec<Vec<f32>> = (0..64).map(|_| (0..256).map(|x| x as f32 / 255.0).collect()).collect();
let row = &cutout_on(&ramp, &[("numberOfLevels", 4.0), ("edgeSimplicity", 0.0), ("edgeFidelity", 3.0)])[32];
for (x, want) in [(100, 95.5), (160, 159.5), (230, 223.5)] {
assert!((row[x] * 255.0 - want).abs() < 0.6, "band at {x}: {} vs {want}", row[x] * 255.0);
}
for step in [64usize, 128, 192] {
assert!(row[step - 2] < row[step + 1] - 0.2, "a step at {step}: {} / {}", row[step - 2], row[step + 1]);
}
// Edge Simplicity drops small shapes by their size alone (measured in the reference
// application: 6 px squares stay at 0–2 and 4 px go; 12 px stay at 4 and 8 px go).
let square = |n: usize| -> Vec<Vec<f32>> {
(0..48).map(|y| (0..48).map(|x| if (20..20 + n).contains(&x) && (20..20 + n).contains(&y) { 0.9 } else { 0.2 }).collect()).collect()
};
for (n, simplicity, kept) in [(6, 0.0, true), (4, 0.0, false), (6, 2.0, true), (12, 4.0, true), (8, 4.0, false), (12, 10.0, false)] {
let out = cutout_on(&square(n), &[("edgeSimplicity", simplicity)]);
let c = 20 + n / 2;
assert_eq!(out[c][c] - out[2][2] > 0.5, kept, "{n} px square at simplicity {simplicity}");
}
}
#[test]
fn cutout_keeps_the_tone_of_surviving_islands_at_low_fidelity() {
for (size, simplicity) in [(6, 0.0), (6, 2.0), (8, 0.0), (12, 4.0)] {
let rows: Vec<Vec<f32>> =
(0..32).map(|y| (0..32).map(|x| if (12..12 + size).contains(&x) && (12..12 + size).contains(&y) { 0.9 } else { 0.2 }).collect()).collect();
let reference = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", 2.0)]);
let centre = 12 + size / 2;
for fidelity in [1.0, 1.5, 2.0] {
let out = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", fidelity)]);
assert!(
(out[centre][centre] - reference[centre][centre]).abs() < 0.02,
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {} vs {}",
out[centre][centre],
reference[centre][centre]
);
assert!(out[centre][centre] - out[2][2] > 0.5, "the island keeps its contrast");
}
}
}
#[test]
fn a_stack_around_a_cutout_is_tile_independent() {
// Effects before a Cutout are traced with it over the whole image; effects after it run
// per tile on the filled-in shapes.
use crate::GalleryFilter as G;
let b = Rect::new(0, 0, 56, 40);
let s = pattern(SampleType::F32);
let fp =
FilterParams::FilterGallery { effects: vec![GalleryEffect::new(G::Spatter), GalleryEffect::new(G::Cutout), GalleryEffect::new(G::GlowingEdges)] };
let a = apply_tiled(&s, &fp, b, b, None, 256, Some(b)).read_region(b);
let t = apply_tiled(&s, &fp, b, b, None, 16, Some(b)).read_region(b);
let worst = a.iter().zip(&t).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(worst < 1e-3, "differs across tilings by {worst}");
}
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
/// solid, and Contrast makes the screen crisper.
#[test]
+75 -22
View File
@@ -1,7 +1,7 @@
//! Blurs: Gaussian, box, motion, radial, surface.
use photocraft_geom::Rect;
use photocraft_raster::Interrupt;
use photocraft_raster::{Interrupt, scratch};
use crate::image::{Edge, Image, premultiply, unpremultiply};
use crate::photo_util::{par_map, par_rows};
@@ -31,6 +31,43 @@ pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
k.into_iter().map(|v| v / s).collect()
}
/// Gaussian Blur's kernel for `radius` (its standard deviation). Sampling the curve at whole
/// pixels leaves a small radius almost no blur: at 0.3 the neighbour weight is about 0.004, a
/// tenth of the spread asked for (#8544). Below 1 the sampling width is instead chosen so the
/// kernel has exactly the variance `radius²`; from 1 up the sampled kernel is within about 0.4%
/// of it (its 3σ truncation), so larger radii are unchanged and the blur grows smoothly from 0.05.
pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec<f32> {
if !(0.05..1.0).contains(&radius) {
return gaussian_kernel(radius);
}
let variance = |k: &[f32]| {
let r = (k.len() / 2) as f32;
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
};
// Sampled at `width` with one fixed half-width for every radius below 1: its variance then
// rises steadily with `width` (the search can't settle on a step where taps are added), and
// the blur grows without the dips a width chosen per radius made at 1/3 and 2/3. Three is the
// half-width `gaussian_kernel` uses at radius 1, so the hand-over is smooth too.
let half = 3;
let sampled = |width: f32| {
let k: Vec<f32> = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect();
let s: f32 = k.iter().sum();
k.into_iter().map(|v| v / s).collect::<Vec<f32>>()
};
let target = radius * radius;
// The sampled kernel's variance only falls short of sigma², so the width lies above `radius`.
let (mut lo, mut hi) = (radius, radius + 1.0);
for _ in 0..40 {
let mid = 0.5 * (lo + hi);
if variance(&sampled(mid)) < target {
lo = mid;
} else {
hi = mid;
}
}
sampled(0.5 * (lo + hi))
}
/// Separable convolution of `src` (premultiplied internally) for `out`.
/// `src` must cover `out` grown by the kernel radii.
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
@@ -41,7 +78,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
// Premultiplied copy of the needed source window.
let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry);
let ww = win.width() as usize;
let mut p = vec![0.0f32; ww * win.height() as usize * n];
let mut p = scratch::Buf::new(ww * win.height() as usize * n, 0.0f32);
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n;
@@ -52,7 +89,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
}
premultiply(&mut p, n, alpha);
// Horizontal pass: (ow × th).
let mut tmp = vec![0.0f32; ow * th * n];
let mut tmp = scratch::Buf::new(ow * th * n, 0.0f32);
for ty in 0..th {
let row = &p[ty * ww * n..(ty + 1) * ww * n];
let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n];
@@ -76,7 +113,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
}
}
// Vertical pass.
let mut res = vec![0.0f32; ow * oh * n];
let mut res = scratch::take(ow * oh * n, 0.0f32);
for oy in 0..oh {
let d = &mut res[oy * ow * n..(oy + 1) * ow * n];
for (i, kv) in ky.iter().enumerate() {
@@ -107,13 +144,12 @@ fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
// Exact kernel for small radii; beyond that three box passes approximate the Gaussian with
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010).
match box_widths(&FilterParams::GaussianBlur { radius }) {
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
None => {
let k = gaussian_kernel(radius);
conv_sep(src, out, &k, &k, ctx.alpha)
}
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010). Built
// from the description other devices run (`separable`), so they blur the same (#5772).
match crate::separable::Separable::of(&FilterParams::GaussianBlur { radius }) {
Some(crate::separable::Separable::Boxes(boxes)) => box_passes(src, out, ctx.alpha, &boxes),
Some(crate::separable::Separable::Kernel(k)) => conv_sep(src, out, &k, &k, ctx.alpha),
None => src.crop(out),
}
}
@@ -146,11 +182,27 @@ pub(crate) fn box_widths(params: &FilterParams) -> Option<Vec<usize>> {
}
/// Scratch buffers of one worker for [`box_blur`].
#[derive(Default)]
struct Scratch {
line: Vec<f32>,
next: Vec<f32>,
acc: Vec<f32>,
/// The source row being read (horizontal passes only).
row: Vec<f32>,
}
impl Scratch {
/// Buffers with room for lines of `line` floats (and the shorter ones after the first pass).
fn new(line: usize, row: usize) -> Self {
Scratch { line: scratch::take_cap(line), next: scratch::take_cap(line), acc: Vec::new(), row: scratch::take_cap(row) }
}
}
impl Drop for Scratch {
fn drop(&mut self) {
scratch::give(std::mem::take(&mut self.line));
scratch::give(std::mem::take(&mut self.next));
scratch::give(std::mem::take(&mut self.row));
}
}
/// One running-sum box pass of radius `r` along a line of elements of `w` floats each (a pixel's
@@ -216,7 +268,7 @@ fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
/// `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 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() {
@@ -270,21 +322,21 @@ pub(crate) fn box_blur(
// Horizontal passes over `used`'s rows only (the repeated rows above and below it would just
// repeat their results), for the output columns. Each row is extended to the passes' reach by
// repeating its end pixels.
let mut h = vec![0.0f32; used.height() as usize * ow * n];
let mut h = scratch::Buf::new(used.height() as usize * ow * n, 0.0f32);
let (left, right) = ((used.x0 - out.x0.saturating_sub(reach)) as usize, (out.x1.saturating_add(reach) - used.x1) as usize);
par_rows(&mut h, ow * ROWS_PER_TASK, n, |task, chunk| {
let (mut row, mut s) = (Vec::new(), Scratch::default());
let mut s = Scratch::new((used.width() as usize + left + right) * n, used.width() as usize * n);
for (k, dst) in chunk.chunks_exact_mut(ow * n).enumerate() {
if ctl.cancelled() {
return;
}
let y = used.y0 + (task * ROWS_PER_TASK + k) as i32;
read(Rect::new(used.x0, y, used.x1, y + 1), &mut row);
premultiply(&mut row, n, alpha);
let (Some(first), Some(last)) = (row.get(..n), row.get(row.len().saturating_sub(n)..)) else { return };
read(Rect::new(used.x0, y, used.x1, y + 1), &mut s.row);
premultiply(&mut s.row, n, alpha);
let (Some(first), Some(last)) = (s.row.get(..n), s.row.get(s.row.len().saturating_sub(n)..)) else { return };
s.line.clear();
(0..left).for_each(|_| s.line.extend_from_slice(first));
s.line.extend_from_slice(&row);
s.line.extend_from_slice(&s.row);
(0..right).for_each(|_| s.line.extend_from_slice(last));
line_passes(dst, n, &radii, &mut s, ctl);
}
@@ -300,11 +352,11 @@ pub(crate) fn box_blur(
}
let (sx, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n);
let row = |y: usize| &h[y * stride + sx..y * stride + sx + w];
let mut s = Scratch::default();
let mut s = Scratch::new((rows + top + bottom) * w, 0);
(0..top).for_each(|_| s.line.extend_from_slice(row(0)));
(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];
let mut res = scratch::take(oh * w, 0.0f32);
line_passes(&mut res, w, &radii, &mut s, ctl);
if ctl.cancelled() {
return (t, Vec::new());
@@ -331,7 +383,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32>
buf.extend_from_slice(src.row(r));
};
let ow = out.width() as usize * n;
let mut res = vec![0.0f32; ow * out.height() as usize];
let mut res = scratch::take(ow * out.height() as usize, 0.0f32);
if res.is_empty() {
return res;
}
@@ -340,6 +392,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32>
for (dst, s) in res.chunks_exact_mut(ow).zip(data.chunks_exact(w)) {
dst[x0..x0 + w].copy_from_slice(s);
}
scratch::give(data);
}
res
}
+73 -2
View File
@@ -2,6 +2,76 @@ use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
/// #8544, rendered: Gaussian Blur at 0.1–0.3 px through the filter itself (the direct-kernel
/// path) visibly softens a hard edge, more with each step, as the kernel predicts.
#[test]
fn small_gaussian_radii_soften_a_rendered_edge() {
let canvas = Rect::new(0, 0, 20, 4);
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(canvas, &[0.0, 0.0, 0.0, 1.0]);
s.fill_rect(Rect::new(10, 0, 20, 4), &[1.0, 1.0, 1.0, 1.0]);
let mut last = 0.0f32;
for radius in [0.1f32, 0.2, 0.3] {
let p = FilterParams::GaussianBlur { radius };
assert!(box_widths(&p).is_none(), "the direct-kernel path");
let out = crate::apply(&s, &p, canvas, canvas, None);
// The last black pixel takes the white side's share of the kernel.
let got = out.pixel(9, 1)[0];
let k = gaussian_blur_kernel(radius);
let want: f32 = k[k.len() / 2 + 1..].iter().sum();
assert!((got - want).abs() <= 1.0 / 255.0, "radius {radius}: {got} vs {want}");
assert!(got >= 1.0 / 255.0 && got > last, "radius {radius}: {got} after {last}");
last = got;
}
}
/// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly
/// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged.
#[test]
fn small_gaussian_radii_blur_smoothly() {
let spread = |k: &[f32]| {
let r = (k.len() / 2) as f32;
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
};
let mut last = 0.0f32;
for step in 1..=20 {
let radius = step as f32 * 0.05;
let k = gaussian_blur_kernel(radius);
assert!((k.iter().sum::<f32>() - 1.0).abs() < 1e-5, "normalised at {radius}");
if radius < 1.0 {
assert!((spread(&k) - radius * radius).abs() < 1e-4, "variance at {radius}: {}", spread(&k));
}
// A hard 0 → 1 edge: the first pixel past it takes the kernel's tail beyond the centre.
let c = k.len() / 2;
let edge: f32 = k[c + 1..].iter().sum();
assert!(edge > last, "radius {radius}: edge {edge} after {last}");
if radius > 0.1 {
assert!(edge - last < 0.05, "radius {radius}: jump {} from {last}", edge - last);
}
last = edge;
}
assert!(gaussian_blur_kernel(0.1).len() >= 3, "0.1 blurs");
// No step where the variance-matched range hands over to plain sampling.
let edge = |r: f32| {
let k = gaussian_blur_kernel(r);
k[k.len() / 2 + 1..].iter().sum::<f32>()
};
assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0));
// No dip where a per-radius width used to add taps (1/3 and 2/3).
for pair in [(0.33f32, 0.34f32), (0.66, 0.67)] {
assert!(edge(pair.1) > edge(pair.0), "edge falls from {} to {}", pair.0, pair.1);
}
// Exact variance right up to the hand-over, past where `gaussian_kernel` changes width.
for radius in [0.98f32, 0.995, 0.998, 0.9995] {
let k = gaussian_blur_kernel(radius);
assert!((spread(&k) - radius * radius).abs() < 2e-5, "variance at {radius}: {}", spread(&k));
}
for radius in [1.0, 1.5, 3.0, 4.0] {
assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}");
}
assert_eq!(gaussian_blur_kernel(0.0), vec![1.0]);
}
#[test]
fn radial_blur_samples_large_arcs_beyond_64_intervals() {
let bounds = Rect::new(0, 0, 1000, 1000);
@@ -134,7 +204,7 @@ fn direct_boxes(img: &Image, out: Rect, boxes: &[usize], alpha: bool) -> Vec<f32
let rect = out.inflate(left);
cur = Image { rect, ch: img.ch, data: conv_sep(&cur, rect, &k, &k, alpha) };
}
cur.data
cur.into_data()
}
/// Noisy pixels over `r` (alpha varying, some fully transparent).
@@ -411,7 +481,8 @@ fn line_passes_stop_between_passes_when_cancelled() {
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 s = Scratch::new(0, 0);
s.line.extend_from_slice(&line);
let mut dst = vec![0.0f32; len * w];
line_passes(&mut dst, w, &radii, &mut s, ctl);
dst
+324
View File
@@ -0,0 +1,324 @@
//! Outlines: each chain fitted once into a straight-sided polyline, with its corners kept and its
//! runs simplified (Douglas–Peucker).
use super::{Pt, Settings};
/// `a - b`.
fn sub(a: Pt, b: Pt) -> Pt {
[a[0] - b[0], a[1] - b[1]]
}
/// Length of `a`.
fn len(a: Pt) -> f32 {
(a[0] * a[0] + a[1] * a[1]).sqrt()
}
/// Fits one chain: corners kept, runs simplified to straight segments. Its ends stay where they are, so
/// chains meeting at a junction still meet.
pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
let n = pts.len();
if n < 3 {
return pts.to_vec();
}
let original = pts;
let rounded = round_off(pts, closed, s.rounding());
let pts = &rounded[..];
let at = |i: isize| pts[i.rem_euclid(n as isize) as usize];
// Turn at each point, measured over a few cracks either side.
const K: isize = 3;
let turn = |i: isize| {
let (a, b) = (sub(at(i), at(i - K)), sub(at(i + K), at(i)));
let (la, lb) = (len(a), len(b));
if la < 1e-6 || lb < 1e-6 { 0.0 } else { ((a[0] * b[0] + a[1] * b[1]) / (la * lb)).clamp(-1.0, 1.0).acos() }
};
let span = if closed { 0..n as isize } else { K..(n as isize - K) };
let turns: Vec<(isize, f32)> = span.clone().map(|i| (i, turn(i))).collect();
let limit = s.corner_turn();
let mut corners: Vec<usize> = Vec::new();
for &(i, t) in &turns {
if t <= limit {
continue;
}
// Only the sharpest point of a bend.
let sharpest = (-K..=K).all(|o| {
let j = i + o;
let in_span = closed || span.contains(&j);
!in_span || o == 0 || {
let tj = turn(j);
tj < t || (tj == t && o > 0)
}
});
if sharpest {
corners.push(i.rem_euclid(n as isize) as usize);
}
}
// Square a corner back where the pixels cut it: where the straight runs either side meet.
let mut p = pts.to_vec();
for &i in &corners {
let ii = i as isize;
if let Some(q) = intersect(at(ii - K), at(ii - 1), at(ii + 1), at(ii + K))
&& len(sub(q, at(ii))) <= 1.0
{
p[i] = q;
}
}
let mut keep: Vec<usize> = if closed { corners.clone() } else { std::iter::once(0).chain(corners.iter().copied()).chain(std::iter::once(n - 1)).collect() };
keep.sort_unstable();
keep.dedup();
let mut is_corner = vec![false; n];
for &k in &keep {
is_corner[k] = true;
}
if closed && keep.len() < 2 {
// A smooth loop: split it at two far-apart points (not corners) to simplify each half.
let a = keep.first().copied().unwrap_or(0);
let far = (0..n).max_by(|&i, &j| len(sub(p[i], p[a])).total_cmp(&len(sub(p[j], p[a])))).unwrap_or(n / 2);
keep = if a == far { vec![a, (a + n / 2) % n] } else { vec![a.min(far), a.max(far)] };
}
// Douglas–Peucker between consecutive kept points.
let tol = s.tolerance();
let mut verts: Vec<usize> = Vec::new();
let pieces = if closed { keep.len() } else { keep.len() - 1 };
for k in 0..pieces {
let a = keep[k];
let b = if k + 1 < keep.len() { keep[k + 1] } else { keep[0] + n };
verts.push(a);
let idx: Vec<usize> = (a..=b).collect();
let mut marks = vec![false; idx.len()];
simplify(&idx.iter().map(|&i| p[i % n]).collect::<Vec<_>>(), 0, idx.len() - 1, tol, &mut marks);
verts.extend(idx.iter().zip(&marks).skip(1).take(idx.len().saturating_sub(2)).filter(|m| *m.1).map(|(&i, _)| i % n));
}
if !closed {
verts.push(n - 1);
}
let v: Vec<Pt> = verts.iter().map(|&i| p[i]).collect();
let corner: Vec<bool> = verts.iter().map(|&i| is_corner[i]).collect();
let _ = &corner;
let mut out = v.clone();
if closed {
out.extend(v.first().copied());
}
if closed {
let before = signed_area(original);
let after = signed_area(&out);
// Region merging decides which islands survive. Rounding and simplification must not
// erase one (or reverse its hole): keep the original shared outline if half its area
// would be lost. Close it explicitly because the fill consumes consecutive segments.
if before * after <= 0.0 || after.abs() < before.abs() * 0.5 {
let mut out = original.to_vec();
out.extend(original.first().copied());
return out;
}
}
out
}
/// Signed loop area, relative to its first point to avoid cancellation far from the origin.
fn signed_area(pts: &[Pt]) -> f64 {
let Some(origin) = pts.first() else { return 0.0 };
pts.iter()
.zip(pts.iter().cycle().skip(1))
.map(|(a, b)| {
let (ax, ay) = (f64::from(a[0]) - f64::from(origin[0]), f64::from(a[1]) - f64::from(origin[1]));
let (bx, by) = (f64::from(b[0]) - f64::from(origin[0]), f64::from(b[1]) - f64::from(origin[1]));
ax * by - bx * ay
})
.sum::<f64>()
* 0.5
}
/// Two passes of a moving average over `r` points either side; an open chain's ends (junctions)
/// stay put, and the window shrinks towards them so they don't pull the outline.
fn round_off(pts: &[Pt], closed: bool, r: usize) -> Vec<Pt> {
let n = pts.len();
let mut p = pts.to_vec();
if r == 0 || n < 3 {
return p;
}
for _ in 0..2 {
let src = p.clone();
for (i, q) in p.iter_mut().enumerate() {
let rr = if closed { r.min((n - 1) / 2) } else { r.min(i).min(n - 1 - i) };
if rr == 0 {
continue;
}
let (mut sx, mut sy) = (0.0f32, 0.0f32);
for o in -(rr as isize)..=rr as isize {
let s = src[(i as isize + o).rem_euclid(n as isize) as usize];
sx += s[0];
sy += s[1];
}
let k = (2 * rr + 1) as f32;
*q = [sx / k, sy / k];
}
}
p
}
/// Where the lines through `a0`–`a1` and `b0`–`b1` cross, if they aren't parallel.
fn intersect(a0: Pt, a1: Pt, b0: Pt, b1: Pt) -> Option<Pt> {
let (da, db) = (sub(a1, a0), sub(b1, b0));
let den = da[0] * db[1] - da[1] * db[0];
if den.abs() < 1e-6 {
return None;
}
let t = ((b0[0] - a0[0]) * db[1] - (b0[1] - a0[1]) * db[0]) / den;
Some([a0[0] + da[0] * t, a0[1] + da[1] * t])
}
/// Marks the points of `p[a..=b]` that Douglas–Peucker keeps at tolerance `tol`.
fn simplify(p: &[Pt], a: usize, b: usize, tol: f32, marks: &mut [bool]) {
let mut stack = vec![(a, b)];
while let Some((a, b)) = stack.pop() {
if b <= a + 1 {
continue;
}
let (pa, d) = (p[a], sub(p[b], p[a]));
let l = len(d);
let mut best = (a, 0.0f32);
for (i, q) in p.iter().enumerate().take(b).skip(a + 1) {
let e = sub(*q, pa);
let dist = if l < 1e-6 { len(e) } else { (e[0] * d[1] - e[1] * d[0]).abs() / l };
if dist > best.1 {
best = (i, dist);
}
}
if best.1 > tol {
marks[best.0] = true;
stack.push((a, best.0));
stack.push((best.0, b));
}
}
}
/// Bounding box `[x0, y0, x1, y1]` of `pts`.
pub(super) fn bounds(pts: &[Pt]) -> [f32; 4] {
pts.iter().fold([f32::MAX, f32::MAX, f32::MIN, f32::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])])
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(fidelity: f32) -> Settings {
Settings { levels: 4, simplicity: 2.0, fidelity }
}
/// The outline of a square from (2, 2) to (12, 12), one point per unit, starting at a corner.
fn square() -> Vec<Pt> {
let mut p = Vec::new();
for i in 0..10 {
p.push([2.0 + i as f32, 2.0]);
}
for i in 0..10 {
p.push([12.0, 2.0 + i as f32]);
}
for i in 0..10 {
p.push([12.0 - i as f32, 12.0]);
}
for i in 0..10 {
p.push([2.0, 12.0 - i as f32]);
}
p
}
fn close(a: Pt, b: Pt) -> bool {
len(sub(a, b)) < 1e-3
}
#[test]
fn douglas_peucker_keeps_only_points_off_the_line() {
// An L, slightly ragged: only its corner is further from the end-to-end line than the
// tolerance, and the two arms are straight within it.
let wobble = |i: usize| 0.05 * (i % 2) as f32;
let p: Vec<Pt> = (0..9).map(|i| if i <= 4 { [i as f32, wobble(i)] } else { [4.0 + wobble(i), (i - 4) as f32] }).collect();
let mut marks = vec![false; p.len()];
simplify(&p, 0, 8, 0.5, &mut marks);
assert_eq!(marks.iter().filter(|m| **m).count(), 1, "{marks:?}");
assert!(marks[4]);
let mut marks = vec![false; p.len()];
simplify(&p, 0, 8, 3.0, &mut marks);
assert!(marks.iter().all(|m| !m), "under the tolerance nothing is kept");
}
#[test]
fn lines_cross_where_they_should() {
let q = intersect([0.0, 0.0], [1.0, 1.0], [0.0, 2.0], [1.0, 1.0]);
assert!(q.is_some_and(|q| close(q, [1.0, 1.0])), "{q:?}");
assert_eq!(intersect([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [2.0, 1.0]), None);
}
#[test]
fn rounding_off_keeps_an_open_chains_ends() {
let p: Vec<Pt> = (0..12).map(|i| [i as f32, if i % 2 == 0 { 0.0 } else { 1.0 }]).collect();
let r = round_off(&p, false, 3);
assert_eq!((r[0], r[11]), (p[0], p[11]));
assert!(r[5][1] > 0.2 && r[5][1] < 0.8, "the zigzag is averaged out: {:?}", r[5]);
assert_eq!(round_off(&p, false, 0), p);
}
#[test]
fn a_square_keeps_its_four_corners_and_straight_sides() {
let out = fit(&square(), true, &settings(2.0));
for c in [[2.0, 2.0], [12.0, 2.0], [12.0, 12.0], [2.0, 12.0]] {
assert!(out.iter().any(|p| close(*p, c)), "corner {c:?} lost: {out:?}");
}
let on_side = |p: &Pt| [p[0] - 2.0, p[0] - 12.0, p[1] - 2.0, p[1] - 12.0].iter().any(|d| d.abs() < 1e-3);
assert!(out.iter().all(on_side), "{out:?}");
assert!(out.len() < 10, "the sides are simplified to their ends: {out:?}");
}
#[test]
fn a_rounded_corner_is_squared_back() {
// The pixels cut the corner at (12, 2): the outline goes (11, 2), (12, 3) instead.
let mut p = square();
p[10] = [11.5, 2.5];
let out = fit(&p, true, &settings(2.0));
assert!(out.iter().any(|q| close(*q, [12.0, 2.0])), "{out:?}");
}
#[test]
fn an_open_chain_ends_where_it_started_at_any_fidelity() {
let p: Vec<Pt> = (0..30).map(|i| [i as f32, (i as f32 / 5.0).sin() * 4.0 + 0.3 * (i % 3) as f32]).collect();
for f in [1.0, 2.0, 3.0] {
let out = fit(&p, false, &settings(f));
assert_eq!((out.first(), out.last()), (p.first(), p.last()), "fidelity {f}");
}
}
#[test]
fn fitting_keeps_small_islands_and_their_winding() {
use super::super::edges::follow_edges;
let (w, h) = (32, 32);
for size in [2, 3, 4, 6] {
let reg: Vec<u32> = (0..w * h).map(|i| u32::from((12..12 + size).contains(&(i % w)) && (12..12 + size).contains(&(i / w)))).collect();
let colours = [[0.2; 3], [0.9; 3]];
let c: Vec<_> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, _) = follow_edges(&reg, &colours, &c, w, h);
let (pts, closed) = chains.iter().find(|(_, closed)| *closed).unwrap();
assert!(*closed);
let area = |p: &[Pt]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64).sum::<f64>() / 2.0;
let original = area(pts);
for simplicity in [0.0, 2.0] {
let s = Settings { simplicity, ..settings(2.0) };
if size * size < s.min_area() as usize {
continue;
}
for fidelity in [1.0, 1.5, 2.0, 3.0] {
let out = fit(pts, true, &Settings { fidelity, ..s });
let fitted = area(&out);
assert!(
fitted * original > 0.0 && fitted.abs() >= original.abs() * 0.5,
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {original} -> {fitted}"
);
assert_eq!(out.first(), out.last(), "fallbacks must close the loop for the fill");
}
}
}
}
#[test]
fn bounds_cover_every_point() {
assert_eq!(bounds(&[[1.0, 5.0], [-2.0, 3.0], [4.0, 4.0]]), [-2.0, 3.0, 4.0, 5.0]);
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Edges: the cracks between regions, linked into chains from junction to junction, each crack
//! placed where the anti-aliasing says the edge is.
use super::{OUT, Pt, Px};
use crate::par;
/// A chain's points, and whether it is closed.
pub(super) type Chain = (Vec<Pt>, bool);
/// A pixel, or a lattice point (the corner at a pixel's top left).
type Cell = (i64, i64);
/// A step along the lattice, y down.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Dir {
E,
W,
S,
N,
}
/// Links the cracks between regions into chains. Each chain is its points (the junctions it runs
/// between, if any, and one sub-pixel point per crack) and whether it is closed; its sides are
/// the regions on its left and right (y down).
pub(super) fn follow_edges(reg: &[u32], colours: &[Px], c: &[Px], w: usize, h: usize) -> (Vec<Chain>, Vec<(u32, u32)>) {
let (wi, hi) = (w as i64, h as i64);
let at = |x: i64, y: i64| -> u32 { if x < 0 || y < 0 || x >= wi || y >= hi { OUT } else { reg[y as usize * w + x as usize] } };
let hcount = w * (h + 1);
// Horizontal crack (x, y)–(x+1, y) is `y * w + x`; vertical (x, y)–(x, y+1) follows them.
let hc = |x: i64, y: i64| (y as usize) * w + x as usize;
let vc = |x: i64, y: i64| hcount + (y as usize) * (w + 1) + x as usize;
let mut visited = vec![false; hcount + (w + 1) * h];
// The crack leaving lattice point (x, y) in `d`: its id, the pixels on its left and right,
// and the point it reaches.
let step = |x: i64, y: i64, d: Dir| -> Option<(usize, Cell, Cell, Cell)> {
match d {
Dir::E if x < wi => Some((hc(x, y), (x, y - 1), (x, y), (x + 1, y))),
Dir::W if x > 0 => Some((hc(x - 1, y), (x - 1, y), (x - 1, y - 1), (x - 1, y))),
Dir::S if y < hi => Some((vc(x, y), (x, y), (x - 1, y), (x, y + 1))),
Dir::N if y > 0 => Some((vc(x, y - 1), (x - 1, y - 1), (x, y - 1), (x, y - 1))),
_ => None,
}
};
// Which cracks separate two regions, and which lattice points are junctions, worked out
// once (in parallel) before the walk.
let mut is_edge = vec![false; visited.len()];
let (hb, vb) = is_edge.split_at_mut(hcount);
par::chunks_mut(hb, w, true, |y, row| {
for (x, b) in row.iter_mut().enumerate() {
*b = at(x as i64, y as i64 - 1) != at(x as i64, y as i64);
}
});
par::chunks_mut(vb, w + 1, true, |y, row| {
for (x, b) in row.iter_mut().enumerate() {
*b = at(x as i64 - 1, y as i64) != at(x as i64, y as i64);
}
});
let boundary = |x: i64, y: i64, d: Dir| step(x, y, d).filter(|s| is_edge[s.0]);
let mut is_junction = vec![false; (w + 1) * (h + 1)];
par::chunks_mut(&mut is_junction, w + 1, true, |y, row| {
let y = y as i64;
for (x, j) in row.iter_mut().enumerate() {
let x = x as i64;
let deg = [Dir::E, Dir::W, Dir::S, Dir::N].iter().filter(|&&d| boundary(x, y, d).is_some()).count();
*j = deg >= 3 || (deg > 0 && (x == 0 || x == wi) && (y == 0 || y == hi));
}
});
let junction = |x: i64, y: i64| is_junction[y as usize * (w + 1) + x as usize];
// One point per crack: its middle, moved along the normal from left to right pixel by what
// their colours say about where the edge really is.
let crack_point = |l: (i64, i64), r: (i64, i64)| -> Pt {
let mid = [(l.0 + r.0 + 1) as f32 / 2.0, (l.1 + r.1 + 1) as f32 / 2.0];
let (n0, n1) = ((r.0 - l.0) as f32, (r.1 - l.1) as f32);
let (ra, rb) = (at(l.0, l.1), at(r.0, r.1));
let (Some(ca), Some(cb)) = (colours.get(ra as usize), colours.get(rb as usize)) else { return mid };
let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]];
let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if dd < 1e-4 {
return mid;
}
// How far each pixel's colour is from its own region's towards the other's (0–1).
let frac = |p: (i64, i64)| {
let q = c[p.1 as usize * w + p.0 as usize];
((q[0] - ca[0]) * d[0] + (q[1] - ca[1]) * d[1] + (q[2] - ca[2]) * d[2]) / dd
};
let (fl, fr) = (frac(l).clamp(0.0, 1.0), frac(r).clamp(0.0, 1.0));
let off = ((1.0 - fr) - fl).clamp(-0.5, 0.5);
[mid[0] + n0 * off, mid[1] + n1 * off]
};
let mut chains = Vec::new();
let mut sides = Vec::new();
let mut walk = |x: i64, y: i64, first: Dir, visited: &mut Vec<bool>| {
let open = junction(x, y);
let mut pts = if open { vec![[x as f32, y as f32]] } else { Vec::new() };
let (mut cx, mut cy, mut d) = (x, y, first);
let mut side = (OUT, OUT);
let mut closed = !open;
while let Some((id, l, r, to)) = boundary(cx, cy, d) {
if visited[id] {
break;
}
visited[id] = true;
if pts.len() <= usize::from(open) {
side = (at(l.0, l.1), at(r.0, r.1));
}
pts.push(crack_point(l, r));
(cx, cy) = to;
if junction(cx, cy) {
pts.push([cx as f32, cy as f32]);
closed = false;
break;
}
let back = match d {
Dir::E => Dir::W,
Dir::W => Dir::E,
Dir::S => Dir::N,
Dir::N => Dir::S,
};
let Some(next) = [Dir::E, Dir::S, Dir::W, Dir::N].into_iter().find(|&nd| nd != back && boundary(cx, cy, nd).is_some_and(|b| !visited[b.0])) else {
break;
};
d = next;
}
if pts.len() >= 2 {
chains.push((pts, closed));
sides.push(side);
}
};
for y in 0..=hi {
for x in 0..=wi {
if junction(x, y) {
for d in [Dir::E, Dir::S, Dir::W, Dir::N] {
if boundary(x, y, d).is_some_and(|b| !visited[b.0]) {
walk(x, y, d, &mut visited);
}
}
}
}
}
// What is left are closed loops with no junction (an island inside one other region).
for y in 0..=hi {
for x in 0..=wi {
for d in [Dir::E, Dir::S] {
if boundary(x, y, d).is_some_and(|b| !visited[b.0]) {
walk(x, y, d, &mut visited);
}
}
}
}
(chains, sides)
}
#[cfg(test)]
mod tests {
use super::*;
/// Regions drawn as digits, one per pixel, with grey `colours` (region `k` is `k / 4`).
fn regions(rows: &[&str]) -> (Vec<u32>, Vec<Px>, usize, usize) {
let reg: Vec<u32> = rows.iter().flat_map(|r| r.bytes()).map(|b| u32::from(b - b'0')).collect();
let n = reg.iter().max().map_or(0, |m| m + 1);
(reg, (0..n).map(|k| [k as f32 / 4.0; 3]).collect(), rows[0].len(), rows.len())
}
/// The chain whose sides are regions `a` and `b`, either way round.
fn between(chains: &[Chain], sides: &[(u32, u32)], a: u32, b: u32) -> Vec<Chain> {
chains.iter().zip(sides).filter(|(_, s)| **s == (a, b) || **s == (b, a)).map(|(c, _)| c.clone()).collect()
}
#[test]
fn the_edge_between_two_regions_is_one_chain_between_junctions() {
let (reg, colours, w, h) = regions(&["000111", "000111", "000111", "000111"]);
let c: Vec<Px> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let inner = between(&chains, &sides, 0, 1);
assert_eq!(inner.len(), 1, "{chains:?}");
let (pts, closed) = &inner[0];
assert!(!closed);
// From junction to junction (where it meets the canvas edge), one point per crack.
let ends = [pts[0], pts[pts.len() - 1]];
assert!(ends.contains(&[3.0, 0.0]) && ends.contains(&[3.0, 4.0]), "{pts:?}");
assert_eq!(pts.len(), 2 + 4);
assert!(pts.iter().all(|p| p[0] == 3.0), "flat colours: the edge is on the crack: {pts:?}");
// Every crack between two regions, the canvas edge included, is in exactly one chain.
let cracks: usize = chains.iter().map(|(p, closed)| p.len() - if *closed { 0 } else { 2 }).sum();
assert_eq!(cracks, 4 + 2 * (w + h));
}
#[test]
fn an_island_is_one_closed_chain() {
let (reg, colours, w, h) = regions(&["00000", "01100", "01100", "00000"]);
let c: Vec<Px> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let island = between(&chains, &sides, 0, 1);
assert_eq!(island.len(), 1);
assert!(island[0].1, "closed");
assert_eq!(island[0].0.len(), 8, "one point per crack around the 2x2 island");
}
#[test]
fn an_anti_aliased_edge_is_placed_inside_its_pixel() {
// Region 0 is black and region 1 white; the last column of region 0 is half white, so
// the edge runs down the middle of that column.
let (reg, _, w, h) = regions(&["000111", "000111", "000111"]);
let colours = vec![[0.0; 3], [1.0; 3]];
let c: Vec<Px> = reg.iter().enumerate().map(|(i, &r)| if i % w == 2 { [0.5; 3] } else { colours[r as usize] }).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let inner = between(&chains, &sides, 0, 1);
let pts = &inner[0].0;
assert!(pts[1..pts.len() - 1].iter().all(|p| (p[0] - 2.5).abs() < 1e-5), "{pts:?}");
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Fill: exact-area scan conversion of the traced shapes, so edges are anti-aliased and shapes
//! that share an edge sum to full coverage.
use super::Pt;
/// Accumulates the signed area of segment `a`–`b` into `acc` (rows of `stride` cells, `h` rows
/// visible, columns `0..stride - 2` visible): the part left of the tile is drawn down its left
/// edge (it still winds the pixels to its right), the part right of it is dropped.
pub(super) fn line_clipped(acc: &mut [f32], stride: usize, h: usize, a: Pt, b: Pt) {
if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) || a[1] == b[1] {
return;
}
let wf = (stride - 2) as f32;
// At most the two ends and two crossings; on the stack, as this runs for every segment drawn.
let mut buf = [0.0f32, 1.0, 0.0, 0.0];
let mut n = 2;
for x in [0.0, wf] {
let t = (x - a[0]) / (b[0] - a[0]);
if t > 0.0 && t < 1.0 {
buf[n] = t;
n += 1;
}
}
let cuts = &mut buf[..n];
cuts.sort_by(f32::total_cmp);
for t in cuts.windows(2) {
let p = [a[0] + (b[0] - a[0]) * t[0], a[1] + (b[1] - a[1]) * t[0]];
let q = [a[0] + (b[0] - a[0]) * t[1], a[1] + (b[1] - a[1]) * t[1]];
let mx = (p[0] + q[0]) / 2.0;
if mx >= wf {
continue;
}
if mx < 0.0 {
line(acc, stride, h, [0.0, p[1]], [0.0, q[1]]);
} else {
line(acc, stride, h, [p[0].clamp(0.0, wf), p[1]], [q[0].clamp(0.0, wf), q[1]]);
}
}
}
/// Signed-area accumulation of one segment inside the tile (the scanline method of font
/// rasterisers): after a running sum along each row, a cell holds the coverage winding.
fn line(acc: &mut [f32], stride: usize, h: usize, p0: Pt, p1: Pt) {
if p0[1] == p1[1] {
return;
}
let (dir, p0, p1) = if p0[1] < p1[1] { (1.0f32, p0, p1) } else { (-1.0, p1, p0) };
let dxdy = (p1[0] - p0[0]) / (p1[1] - p0[1]);
let mut x = p0[0];
let ystart = p0[1].max(0.0);
if p0[1] < 0.0 {
x -= p0[1] * dxdy;
}
let yend = p1[1].min(h as f32);
if ystart >= yend {
return;
}
let max_x = (stride - 1) as f32;
for y in (ystart as usize)..(yend.ceil() as usize).min(h) {
let ls = y * stride;
let dy = ((y + 1) as f32).min(p1[1]) - (y as f32).max(p0[1]);
let xnext = x + dxdy * dy;
let d = dy * dir;
let (x0, x1) = if x < xnext { (x, xnext) } else { (xnext, x) };
let (x0, x1) = (x0.clamp(0.0, max_x - 1.0), x1.clamp(0.0, max_x - 1.0));
let x0f = x0.floor();
let x0i = x0f as usize;
let x1c = x1.ceil();
let x1i = x1c as usize;
if x1i <= x0i + 1 {
let xmf = 0.5 * (x0 + x1) - x0f;
acc[ls + x0i] += d - d * xmf;
acc[ls + x0i + 1] += d * xmf;
} else {
let sx = (x1 - x0).recip();
let x0r = x0 - x0f;
let a0 = 0.5 * sx * (1.0 - x0r) * (1.0 - x0r);
let x1r = x1 - x1c + 1.0;
let am = 0.5 * sx * x1r * x1r;
acc[ls + x0i] += d * a0;
if x1i == x0i + 2 {
acc[ls + x0i + 1] += d * (1.0 - a0 - am);
} else {
let a1 = sx * (1.5 - x0r);
acc[ls + x0i + 1] += d * (a1 - a0);
for xi in x0i + 2..x1i - 1 {
acc[ls + xi] += d * sx;
}
let a2 = a1 + (x1i - x0i - 3) as f32 * sx;
acc[ls + x1i - 1] += d * (1.0 - a2 - am);
}
acc[ls + x1i] += d * am;
}
x = xnext;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_square_covers_its_area_exactly() {
let mut acc = vec![0.0f32; 12 * 10];
let sq = [[2.25, 2.5], [7.75, 2.5], [7.75, 6.5], [2.25, 6.5], [2.25, 2.5]];
for s in sq.windows(2) {
line_clipped(&mut acc, 12, 10, s[0], s[1]);
}
let mut total = 0.0;
for y in 0..10 {
let mut sum = 0.0;
for x in 0..10 {
sum += acc[y * 12 + x];
total += sum.abs().min(1.0);
}
}
assert!((total - 5.5 * 4.0).abs() < 1e-4, "{total}");
}
}
+367
View File
@@ -0,0 +1,367 @@
//! Cutout (Filter Gallery › Artistic): the image as flat sheets of cut paper.
//!
//! What it does follows the reference application, measured on grey ramps, hue sweeps, squares of
//! known size and a portrait:
//!
//! 1. **Tone bands**: each pixel's band is the plain mean of its three channels, cut into Number of
//! Levels equal steps (four levels: below 64, 128, 192 and the rest, on 0–255). Colours of
//! different hue but the same mean share a band.
//! 2. **Regions**: 4-connected areas of one band. Regions smaller than the Edge Simplicity area
//! merge, smallest first, into the neighbour they share most border with. The area depends on
//! the slider alone, not on the image size. Each region is filled with the mean colour of its own
//! pixels, so a region mixing hues comes out as their average.
//! 3. **Edges**: the cracks between regions are linked into chains from junction to junction
//! (where three regions meet, or the canvas edge). Each crack is moved along its normal to where
//! the pixels on either side say the edge is (an anti-aliased pixel is part one colour, part the
//! other). A chain is fitted once, so two neighbouring shapes share one and the same edge.
//! 4. **Outlines**: straight-sided polygons. Sharp bends stay corners; the runs between are
//! simplified (Douglas–Peucker) to a tolerance that grows with Edge Simplicity and shrinks with
//! Edge Fidelity.
//! 5. **Fill**: each region is scan-converted with exact area coverage, so edges are anti-aliased
//! and the coverages of shapes that share an edge sum to one: no seams.
//!
//! Tracing reads the whole image and runs once; filling runs per tile and gives the same pixels
//! whatever the tiling.
//!
//! Each stage is its own module: [`regions`] (1–2), [`edges`] (3), [`curves`] (4) and [`fill`]
//! (5); [`trace`] runs them in turn.
use photocraft_geom::Rect;
use crate::par;
mod curves;
mod edges;
mod fill;
mod regions;
mod util;
use curves::{bounds, fit};
use edges::follow_edges;
use fill::line_clipped;
use regions::{components, merge_regions};
type Px = [f32; 3];
type Pt = [f32; 2];
/// The tone band of a pixel: the mean of its three channels in `levels` equal steps.
fn band(p: Px, levels: usize) -> u8 {
let v = (p[0] + p[1] + p[2]) / 3.0;
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
((v * levels as f32) as usize).min(levels - 1) as u8
}
/// Region id of the area outside the image.
const OUT: u32 = u32::MAX;
/// The filter's three sliders.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Settings {
/// Number of Levels (2–8): how many tone bands.
pub levels: usize,
/// Edge Simplicity (0–10): smaller shapes merge and outlines simplify more.
pub simplicity: f32,
/// Edge Fidelity (1–3): more corners are kept.
pub fidelity: f32,
}
/// Linear interpolation in a per-step table over Edge Simplicity 0–10.
fn by_simplicity(table: &[f32; 11], simplicity: f32) -> f32 {
let s = if simplicity.is_finite() { simplicity.clamp(0.0, 10.0) } else { 0.0 };
let i = (s.floor() as usize).min(9);
let t = s - i as f32;
table[i] + (table[i + 1] - table[i]) * t
}
impl Settings {
/// Regions with fewer pixels merge into a neighbour. Measured in the reference application
/// with squares of 2–96 px: the smallest that survive are 6 px wide at Simplicity 0–2, 12 px
/// at 4, 48 px at 6 and 96 px at 7, the same on a 256 px and a 600 px canvas.
fn min_area(&self) -> u32 {
const AREA: [f32; 11] = [25.0, 25.0, 25.0, 50.0, 100.0, 400.0, 1500.0, 5000.0, 10000.0, 16000.0, 30000.0];
by_simplicity(&AREA, self.simplicity).round() as u32
}
/// Douglas–Peucker tolerance of the straight outlines, in pixels: coarser with more Edge
/// Simplicity, finer with more Edge Fidelity.
fn tolerance(&self) -> f32 {
const TOL: [f32; 11] = [0.5, 0.5, 0.6, 0.8, 1.0, 1.5, 2.5, 4.0, 7.0, 12.0, 20.0];
by_simplicity(&TOL, self.simplicity) * 2f32.powf(2.0 - self.fidelity.clamp(1.0, 3.0))
}
/// Below Edge Fidelity 2 the outline is rounded off before it is fitted: points averaged with
/// this many neighbours either side (3 at fidelity 1).
fn rounding(&self) -> usize {
((2.0 - self.fidelity.clamp(1.0, 3.0)) * 3.0).round().max(0.0) as usize
}
/// Turns sharper than this stay corners.
fn corner_turn(&self) -> f32 {
(100.0 - 22.5 * self.fidelity.clamp(1.0, 3.0)).to_radians()
}
}
/// A traced image: the shapes and their colours, ready to fill any tile.
pub(crate) struct Traced {
/// Document position of the traced window's top-left pixel; outlines are relative to it.
x0: i32,
y0: i32,
/// Fitted edges (polylines), each shared by the one or two regions it separates.
chains: Vec<Vec<Pt>>,
/// Per chain, its bounding box `[x0, y0, x1, y1]`.
chain_boxes: Vec<[f32; 4]>,
regions: Vec<Region>,
}
/// One shape: its flat colour, bounding box and outline.
struct Region {
colour: Px,
bbox: [f32; 4],
/// The chains around the region, reversed where the region is on their right.
edges: Vec<(u32, bool)>,
}
/// Traces `c` (sRGB colour of every pixel of `r`, row-major).
pub(crate) fn trace(c: &[Px], r: Rect, s: &Settings) -> Traced {
let (w, h) = (r.width() as usize, r.height() as usize);
let empty = Traced { x0: r.x0, y0: r.y0, chains: Vec::new(), chain_boxes: Vec::new(), regions: Vec::new() };
if w == 0 || h == 0 || c.len() != w * h {
return empty;
}
// A non-finite sample (32-bit documents can hold them) would make its region's mean colour,
// and so its edges and fill, non-finite: count it as 0 from here on.
let clean = |v: f32| if v.is_finite() { v } else { 0.0 };
let owned: Vec<Px>;
let c: &[Px] = if c.iter().flatten().all(|v| v.is_finite()) {
c
} else {
owned = c.iter().map(|p| p.map(clean)).collect();
&owned
};
let levels = s.levels.clamp(2, 8);
let mut labels = vec![0u8; w * h];
par::chunks_mut(&mut labels, w, true, |y, row| {
for (o, p) in row.iter_mut().zip(&c[y * w..(y + 1) * w]) {
*o = band(*p, levels);
}
});
let (comp, n) = components(&labels, w, h);
let (reg, colours) = merge_regions(&comp, n, c, w, h, s);
let (chains, sides) = follow_edges(&reg, &colours, c, w, h);
let chains: Vec<Vec<Pt>> = par::map(chains.len(), true, |i| fit(&chains[i].0, chains[i].1, s));
let chain_boxes: Vec<[f32; 4]> = chains.iter().map(|ch| bounds(ch)).collect();
let mut regions: Vec<Region> =
colours.into_iter().map(|colour| Region { colour, bbox: [f32::MAX, f32::MAX, f32::MIN, f32::MIN], edges: Vec::new() }).collect();
for (k, &(left, right)) in sides.iter().enumerate() {
for (side, rev) in [(left, false), (right, true)] {
if let Some(g) = regions.get_mut(side as usize) {
g.edges.push((k as u32, rev));
let b = chain_boxes[k];
g.bbox = [g.bbox[0].min(b[0]), g.bbox[1].min(b[1]), g.bbox[2].max(b[2]), g.bbox[3].max(b[3])];
}
}
}
Traced { x0: r.x0, y0: r.y0, chains, chain_boxes, regions }
}
impl Traced {
/// The cut paper over `r` (document coordinates): sRGB colour per pixel, row-major.
pub(crate) fn render(&self, r: Rect) -> Vec<Px> {
let (w, h) = (r.width() as usize, r.height() as usize);
let mut col = vec![[0.0f32; 3]; w * h];
if w == 0 || h == 0 {
return col;
}
let mut weight = vec![0.0f32; w * h];
let stride = w + 2;
let mut acc = vec![0.0f32; stride * h];
let (ox, oy) = ((self.x0 - r.x0) as f32, (self.y0 - r.y0) as f32);
let (wf, hf) = (w as f32, h as f32);
for g in &self.regions {
let b = [g.bbox[0] + ox, g.bbox[1] + oy, g.bbox[2] + ox, g.bbox[3] + oy];
if b[2] <= 0.0 || b[0] >= wf || b[3] <= 0.0 || b[1] >= hf {
continue;
}
for &(k, rev) in &g.edges {
let (Some(pts), Some(cb)) = (self.chains.get(k as usize), self.chain_boxes.get(k as usize)) else { continue };
// Chains left of the tile still count (they wind the rows they cross); chains to
// its right or above or below it don't.
if cb[0] + ox >= wf || cb[3] + oy <= 0.0 || cb[1] + oy >= hf {
continue;
}
for seg in pts.windows(2) {
let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) };
line_clipped(&mut acc, stride, h, [a[0] + ox, a[1] + oy], [z[0] + ox, z[1] + oy]);
}
}
let y0 = b[1].floor().max(0.0) as usize;
let y1 = (b[3].ceil().max(0.0) as usize).min(h);
let xe = ((b[2].ceil().max(0.0) as usize) + 1).min(w);
for y in y0..y1 {
let row = &mut acc[y * stride..(y + 1) * stride];
let mut sum = 0.0f32;
for (x, v) in row.iter().enumerate().take(xe) {
sum += v;
let cov = sum.abs().min(1.0);
if cov > 0.0 {
let i = y * w + x;
let paint = g.colour;
col[i] = [col[i][0] + paint[0] * cov, col[i][1] + paint[1] * cov, col[i][2] + paint[2] * cov];
weight[i] += cov;
}
}
row.fill(0.0);
}
}
for (p, &wt) in col.iter_mut().zip(&weight) {
if wt > 1e-6 {
*p = [p[0] / wt, p[1] / wt, p[2] / wt];
}
}
col
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dist2(a: Px, b: Px) -> f32 {
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)
}
fn settings(levels: usize, simplicity: f32, fidelity: f32) -> Settings {
Settings { levels, simplicity, fidelity }
}
/// Total coverage of every pixel: each pixel is covered exactly once by the shapes.
fn coverage(t: &Traced, r: Rect) -> Vec<f32> {
let (w, h) = (r.width() as usize, r.height() as usize);
let mut weight = vec![0.0f32; w * h];
let stride = w + 2;
let mut acc = vec![0.0f32; stride * h];
for g in &t.regions {
for &(k, rev) in &g.edges {
for seg in t.chains[k as usize].windows(2) {
let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) };
line_clipped(&mut acc, stride, h, a, z);
}
}
for y in 0..h {
let mut sum = 0.0;
for x in 0..w {
sum += acc[y * stride + x];
weight[y * w + x] += sum.abs().min(1.0);
}
acc[y * stride..(y + 1) * stride].fill(0.0);
}
}
weight
}
#[test]
fn shapes_cover_every_pixel_once_with_no_seams() {
// Blobs of four colours touching each other and the canvas edge, with T-junctions,
// saddles and holes.
let (w, h) = (48usize, 36usize);
let pal = [[0.1, 0.1, 0.1], [0.9, 0.2, 0.2], [0.2, 0.7, 0.3], [0.95, 0.95, 0.9]];
let c: Vec<Px> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let k = ((x / 7.0).sin() + (y / 5.0).cos() + (x * y / 90.0).sin()).abs() * 1.6;
pal[(k as usize).min(3)]
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
for fidelity in [1.0, 3.0] {
let t = trace(&c, r, &settings(4, 2.0, fidelity));
assert!(t.regions.len() > 4);
let cov = coverage(&t, r);
let worst = cov.iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max);
assert!(worst < 1e-3, "coverage off by {worst}");
}
}
#[test]
fn edge_fidelity_changes_the_outlines_a_little() {
// In the reference application Edge Fidelity nudges the outlines: on a portrait 1.9% of
// pixels change from fidelity 1 to 2 and 1.2% from 2 to 3. Large smooth shapes here.
let (w, h) = (160usize, 120usize);
let pal = [[0.1, 0.1, 0.1], [0.9, 0.3, 0.2], [0.3, 0.6, 0.9]];
let c: Vec<Px> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let k = ((x / 17.0).sin() * 1.3 + (y / 13.0).cos() + (x * y / 900.0).sin()).abs();
pal[(k as usize).min(2)]
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
let out = |f: f32| trace(&c, r, &settings(3, 2.0, f)).render(r);
let changed = |a: &[Px], b: &[Px]| a.iter().zip(b).filter(|(p, q)| dist2(**p, **q) > 0.01).count() as f32 / a.len() as f32;
let (loose, normal, tight) = (out(1.0), out(2.0), out(3.0));
for (name, a, b) in [("1 vs 2", &loose, &normal), ("2 vs 3", &normal, &tight)] {
let f = changed(a, b);
assert!(f > 0.002 && f < 0.06, "fidelity {name}: {f}");
}
}
#[test]
fn empty_images_stop_before_the_transpose_and_row_passes() {
for r in [Rect::new(0, 0, 0, 4), Rect::new(0, 0, 4, 0), Rect::new(0, 0, 0, 0)] {
let t = trace(&[], r, &settings(4, 2.0, 1.0));
assert!(t.regions.is_empty());
assert!(t.chains.is_empty());
}
}
#[test]
fn filling_does_not_depend_on_the_tiling() {
let (w, h) = (40usize, 30usize);
let c: Vec<Px> = (0..w * h).map(|i| if ((i % w) / 6 + (i / w) / 5) % 3 == 0 { [0.8, 0.3, 0.1] } else { [0.1, 0.3, 0.7] }).collect();
let r = Rect::new(10, 20, 10 + w as i32, 20 + h as i32);
let t = trace(&c, r, &settings(3, 1.0, 2.0));
let whole = t.render(r);
for tile in [Rect::new(10, 20, 23, 31), Rect::new(23, 31, 50, 50), Rect::new(37, 20, 50, 33)] {
let part = t.render(tile);
for y in tile.y0..tile.y1 {
for x in tile.x0..tile.x1 {
let a = whole[((y - r.y0) as usize) * w + (x - r.x0) as usize];
let b = part[((y - tile.y0) as usize) * tile.width() as usize + (x - tile.x0) as usize];
assert!(dist2(a, b) < 1e-8, "({x}, {y}): {a:?} vs {b:?}");
}
}
}
}
/// A NaN or infinite sample must not reach a region's colour, its edges or the fill.
#[test]
fn non_finite_samples_give_finite_covered_output() {
let (w, h) = (24usize, 16usize);
let mut c: Vec<Px> = (0..w * h).map(|i| if i % w < 12 { [0.1, 0.1, 0.1] } else { [0.9, 0.8, 0.7] }).collect();
c[3 * w + 4] = [f32::NAN, 0.5, 0.5];
c[8 * w + 15] = [f32::INFINITY, f32::NEG_INFINITY, 0.2];
c[12 * w + 12] = [f32::NAN; 3];
let r = Rect::new(0, 0, w as i32, h as i32);
for simplicity in [0.0, 4.0] {
let t = trace(&c, r, &settings(4, simplicity, 2.0));
assert!(t.regions.iter().all(|g| g.colour.iter().all(|v| v.is_finite())), "region colours finite");
assert!(t.chains.iter().flatten().flatten().all(|v| v.is_finite()), "outlines finite");
let out = t.render(r);
assert!(out.iter().flatten().all(|v| v.is_finite()), "pixels finite");
let worst = coverage(&t, r).iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max);
assert!(worst < 1e-3, "coverage off by {worst}");
}
}
#[test]
fn noise_merges_into_neighbours_and_a_six_pixel_square_survives_at_simplicity_0() {
let (w, h) = (32usize, 32usize);
let c: Vec<Px> = (0..w * h)
.map(|i| {
// Noise inside one band (1/6 to 2/6 at six levels).
let v = 0.2 + crate::photo_util::hash01((i % w) as i64, (i / w) as i64, 5) * 0.06;
if (11..17).contains(&(i % w)) && (11..17).contains(&(i / w)) { [0.9, 0.9, 0.9] } else { [v, v, v] }
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
assert_eq!(trace(&c, r, &settings(6, 0.0, 2.0)).regions.len(), 2, "the speck stays at simplicity 0");
assert_eq!(trace(&c, r, &settings(6, 10.0, 2.0)).regions.len(), 1, "the speck merges at simplicity 10");
}
}
+180
View File
@@ -0,0 +1,180 @@
//! Regions: connected areas of one tone band, and the merge of small ones into their neighbours.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
use std::hash::BuildHasherDefault;
use super::util::{FxHasher, find};
use super::{Px, Settings};
/// 4-connected components of equal labels, numbered in raster order of their first pixel.
pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec<u32>, usize) {
let mut comp = vec![0u32; w * h];
let mut parent: Vec<u32> = Vec::new();
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let up = (y > 0 && labels[i - w] == labels[i]).then(|| comp[i - w]);
let left = (x > 0 && labels[i - 1] == labels[i]).then(|| comp[i - 1]);
comp[i] = match (up, left) {
(Some(a), Some(b)) => {
let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
let (lo, hi) = (ra.min(rb), ra.max(rb));
parent[hi as usize] = lo;
lo
}
(Some(a), None) | (None, Some(a)) => a,
(None, None) => {
parent.push(parent.len() as u32);
parent.len() as u32 - 1
}
};
}
}
let mut index = vec![u32::MAX; parent.len()];
let mut n = 0u32;
for c in comp.iter_mut() {
let root = find(&mut parent, *c) as usize;
if index[root] == u32::MAX {
index[root] = n;
n += 1;
}
*c = index[root];
}
(comp, n as usize)
}
/// Merges small and sliver components into their neighbours. Returns each pixel's region and
/// each region's mean colour.
pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize, s: &Settings) -> (Vec<u32>, Vec<Px>) {
let mut area = vec![0u32; n];
let mut sum = vec![[0.0f64; 3]; n];
for (&k, p) in comp.iter().zip(c) {
let k = k as usize;
area[k] += 1;
sum[k] = [sum[k][0] + f64::from(p[0]), sum[k][1] + f64::from(p[1]), sum[k][2] + f64::from(p[2])];
}
// Shared border lengths, in cracks.
let mut shared: HashMap<u64, u32, BuildHasherDefault<FxHasher>> = HashMap::default();
for y in 0..h {
for x in 0..w {
let a = comp[y * w + x];
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
if nx < w && ny < h {
let b = comp[ny * w + nx];
if a != b {
*shared.entry((u64::from(a.min(b)) << 32) | u64::from(a.max(b))).or_insert(0) += 1;
}
}
}
}
}
let mut adj: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n];
let mut pairs: Vec<(u64, u32)> = shared.into_iter().collect();
pairs.sort_unstable();
for (key, len) in pairs {
let (a, b) = ((key >> 32) as u32, key as u32);
adj[a as usize].push((b, len));
adj[b as usize].push((a, len));
}
let min_area = s.min_area();
let mergeable = |a: u32| a < min_area;
let mut parent: Vec<u32> = (0..n as u32).collect();
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = (0..n).filter(|&i| mergeable(area[i])).map(|i| Reverse((area[i], i as u32))).collect();
while let Some(Reverse((a, id))) = heap.pop() {
let i = id as usize;
if find(&mut parent, id) != id || area[i] != a || !mergeable(a) {
continue;
}
// The neighbours as they are now, with their total shared border.
let mut nb: Vec<(u32, u32)> = std::mem::take(&mut adj[i]).into_iter().map(|(k, len)| (find(&mut parent, k), len)).filter(|&(k, _)| k != id).collect();
nb.sort_unstable();
nb.dedup_by(|b, a| {
if a.0 == b.0 {
a.1 += b.1;
true
} else {
false
}
});
let Some(&(t, _)) = nb.iter().max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0))) else {
adj[i] = nb;
continue;
};
let ti = t as usize;
parent[i] = t;
area[ti] += area[i];
sum[ti] = [sum[ti][0] + sum[i][0], sum[ti][1] + sum[i][1], sum[ti][2] + sum[i][2]];
// Appended as they are; resolved and summed when `t` itself merges.
adj[ti].extend(nb.into_iter().filter(|&(k, _)| k != t));
if mergeable(area[ti]) {
heap.push(Reverse((area[ti], t)));
}
}
let mut index = vec![u32::MAX; n];
let mut colours = Vec::new();
let mut reg = vec![0u32; comp.len()];
for (r, &k) in reg.iter_mut().zip(comp) {
let root = find(&mut parent, k) as usize;
if index[root] == u32::MAX {
index[root] = colours.len() as u32;
let a = f64::from(area[root].max(1));
colours.push([(sum[root][0] / a) as f32, (sum[root][1] / a) as f32, (sum[root][2] / a) as f32]);
}
*r = index[root];
}
(reg, colours)
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(simplicity: f32) -> Settings {
Settings { levels: 4, simplicity, fidelity: 2.0 }
}
/// Labels drawn as text, one character per pixel.
fn labels(rows: &[&str]) -> (Vec<u8>, usize, usize) {
(rows.iter().flat_map(|r| r.bytes()).collect(), rows[0].len(), rows.len())
}
#[test]
fn components_are_four_connected_and_numbered_in_raster_order() {
let (l, w, h) = labels(&["aab", "abb", "bba"]);
let (comp, n) = components(&l, w, h);
// The top-left `a`s, the `b`s (one area), then the lone `a` (touching the first only
// diagonally), in the order their first pixels come.
assert_eq!(n, 3);
assert_eq!(comp, vec![0, 0, 1, 0, 1, 1, 1, 1, 2]);
// A U shape: both arms are found as separate runs first and joined further down.
let (l, w, h) = labels(&["a.a", "a.a", "aaa"]);
let (comp, n) = components(&l, w, h);
assert_eq!(n, 2);
assert_eq!(comp[0], comp[2]);
}
/// Regions from labels, with one flat colour per label.
fn merged(rows: &[&str], simplicity: f32) -> (Vec<u32>, Vec<Px>) {
let (l, w, h) = labels(rows);
let c: Vec<Px> = l.iter().map(|&b| [f32::from(b - b'a') / 4.0; 3]).collect();
let (comp, n) = components(&l, w, h);
merge_regions(&comp, n, &c, w, h, &settings(simplicity))
}
#[test]
fn small_regions_merge_into_the_neighbour_they_share_most_border_with() {
let rows = ["aaaaaaaaaa", "aaaaaaaaaa", "aacaaaaaaa", "aaccbbbbbb", "aaaabbbbbb", "aaaabbbbbb", "aaaabbbbbb", "aaaabbbbbb"];
// At Edge Simplicity 0 a region needs 25 pixels: the three `c`s go into the `a`s, which
// they share six cracks with (one with the `b`s); the 30 `b`s stay.
let (reg, colours) = merged(&rows, 0.0);
assert_eq!(colours.len(), 2);
assert_eq!(reg[2 * 10 + 2], reg[0]);
assert_ne!(reg[3 * 10 + 4], reg[0]);
// A region's colour is the mean of its pixels: 47 `a`s (0.0) and 3 `c`s (0.5).
let a = colours[reg[0] as usize][0];
assert!((a - 1.5 / 50.0).abs() < 1e-6, "{a}");
// At 4 a region needs 100 pixels, so the `b`s merge too.
assert_eq!(merged(&rows, 4.0).1.len(), 1);
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Small helpers shared by the Cutout stages.
use std::hash::Hasher;
/// Union-find root of `i`, halving the path on the way.
pub(super) fn find(parent: &mut [u32], mut i: u32) -> u32 {
while parent[i as usize] != i {
let p = parent[i as usize];
parent[i as usize] = parent[p as usize];
i = p;
}
i
}
/// A fast deterministic hasher for the region adjacency map.
#[derive(Default)]
pub(super) struct FxHasher(u64);
impl Hasher for FxHasher {
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.write_u64(u64::from(b));
}
}
fn write_u64(&mut self, v: u64) {
self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x517c_c1b7_2722_0a95);
}
fn finish(&self) -> u64 {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_returns_the_root_and_shortens_the_path() {
// 4 -> 3 -> 2 -> 1 -> 0.
let mut parent = vec![0, 0, 1, 2, 3];
assert_eq!(find(&mut parent, 4), 0);
assert_eq!(find(&mut parent, 0), 0);
assert!(parent[4] < 3, "the path was halved: {parent:?}");
assert!(parent.iter().all(|&p| p <= 2));
}
}
+153 -26
View File
@@ -28,28 +28,57 @@ pub(crate) fn reach() -> f32 {
(2 * MAX_LUMA_R + 2 * MAX_CHROMA_R + 6) as f32
}
/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`.
fn guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let len = w * h;
// Interleave the four box-filtered moments to run one pass.
let mut m = vec![0.0f32; len * 4];
for i in 0..len {
m[i * 4] = g[i];
m[i * 4 + 1] = p[i];
m[i * 4 + 2] = g[i] * g[i];
m[i * 4 + 3] = g[i] * p[i];
/// Shared first and second moments of a guide (two filtered channels).
fn guide_moments(g: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
let mut moments = Vec::with_capacity(g.len() * 2);
for &v in g {
moments.extend_from_slice(&[v, v * v]);
}
box_blur_n(&mut m, w, h, 4, r);
let mut ab = vec![0.0f32; len * 2];
for i in 0..len {
let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]);
let var = (mgg - mg * mg).max(0.0);
let a = (mgp - mg * mp) / (var + eps);
ab[i * 2] = a;
ab[i * 2 + 1] = mp - a * mg;
box_blur_n(&mut moments, w, h, 2, r);
moments
}
/// Self-guided luminance: mean(p) = mean(g), mean(g*p) = mean(g*g).
fn guided_luma(g: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let mut ab = guide_moments(g, w, h, r);
for pair in ab.as_chunks_mut::<2>().0.iter_mut() {
let mean = pair[0];
let covariance = pair[1] - mean * mean;
// Keep the original unclamped numerator, including roundoff below zero.
let a = covariance / (covariance.max(0.0) + eps);
pair[0] = a;
pair[1] = mean - a * mean;
}
box_blur_n(&mut ab, w, h, 2, r);
(0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect()
g.iter().zip(ab.as_chunks::<2>().0.iter()).map(|(&v, pair)| pair[0] * v + pair[1]).collect()
}
/// Chroma planes share the cleaned guide's mean and regularised variance.
/// Two guide channels plus two moments and two coefficients per chroma plane:
/// ten filtered channels, versus twelve for two independent guided filters.
fn guided_chroma(g: &[f32], planes: [&mut [f32]; 2], w: usize, h: usize, r: usize, eps: f32, amount: f32) {
let mut stats = guide_moments(g, w, h, r);
for pair in stats.as_chunks_mut::<2>().0.iter_mut() {
pair[1] = (pair[1] - pair[0] * pair[0]).max(0.0) + eps;
}
for plane in planes {
let mut ab = Vec::with_capacity(g.len() * 2);
for (&guide, &p) in g.iter().zip(plane.iter()) {
ab.extend_from_slice(&[p, guide * p]);
}
box_blur_n(&mut ab, w, h, 2, r);
for (pair, guide) in ab.as_chunks_mut::<2>().0.iter_mut().zip(stats.as_chunks::<2>().0.iter()) {
let mean = pair[0];
let a = (pair[1] - guide[0] * mean) / guide[1];
pair[0] = a;
pair[1] = mean - a * guide[0];
}
box_blur_n(&mut ab, w, h, 2, r);
for ((v, &guide), pair) in plane.iter_mut().zip(g).zip(ab.as_chunks::<2>().0.iter()) {
let filtered = pair[0] * guide + pair[1];
*v += (filtered - *v) * amount;
}
}
}
pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec) -> Vec<f32> {
@@ -125,7 +154,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
let r = (1.0 + spec.strength.clamp(0.0, 10.0) * 0.4).round() as usize;
let keep = spec.preserve.clamp(0.0, 100.0) / 100.0;
let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - keep);
let d = guided(&yv, &yv, ww, wh, r.min(MAX_LUMA_R), eps);
let d = guided_luma(&yv, ww, wh, r.min(MAX_LUMA_R), eps);
let k = s.sqrt();
for (v, dv) in yv.iter_mut().zip(d) {
*v += (dv - *v) * k;
@@ -135,12 +164,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
if col > 0.0 && model != Model::Gray {
let r = (1.0 + col * 8.0).round() as usize;
let eps = (0.01 + 0.05 * col).powi(2);
for plane in [&mut c1, &mut c2] {
let d = guided(&yv, plane, ww, wh, r.min(MAX_CHROMA_R), eps);
for (v, dv) in plane.iter_mut().zip(d) {
*v += (dv - *v) * col;
}
}
guided_chroma(&yv, [&mut c1, &mut c2], ww, wh, r.min(MAX_CHROMA_R), eps, col);
}
// Sharpen Details: unsharp mask on luma.
if sh > 0.0 {
@@ -178,3 +202,106 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
}
res
}
#[cfg(test)]
mod tests {
use super::*;
/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`.
fn reference_guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let len = w * h;
// Interleave the four box-filtered moments to run one pass.
let mut m = vec![0.0f32; len * 4];
for i in 0..len {
m[i * 4] = g[i];
m[i * 4 + 1] = p[i];
m[i * 4 + 2] = g[i] * g[i];
m[i * 4 + 3] = g[i] * p[i];
}
box_blur_n(&mut m, w, h, 4, r);
let mut ab = vec![0.0f32; len * 2];
for i in 0..len {
let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]);
let var = (mgg - mg * mg).max(0.0);
let a = (mgp - mg * mp) / (var + eps);
ab[i * 2] = a;
ab[i * 2 + 1] = mp - a * mg;
}
box_blur_n(&mut ab, w, h, 2, r);
(0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect()
}
#[test]
fn shared_statistics_match_independent_filters_bit_for_bit() {
for (w, h) in [(1, 1), (1, 19), (23, 1), (7, 5), (33, 17)] {
let len = w * h;
for flat in [false, true] {
let g: Vec<f32> = (0..len).map(|i| if flat { 0.1 } else { ((i * 37 % 101) as f32 - 20.0) / 63.0 }).collect();
let p: Vec<f32> = (0..len).map(|i| (i * 13 % 71) as f32 / 70.0 - 0.5).collect();
let q: Vec<f32> = (0..len).map(|i| (i * 29 % 83) as f32 / 41.0 - 1.0).collect();
for r in [0, 1, MAX_LUMA_R, MAX_CHROMA_R, 25] {
for eps in [0.000001, 0.0036] {
let expected = reference_guided(&g, &g, w, h, r, eps);
let actual = guided_luma(&g, w, h, r, eps);
assert_bits(&actual, &expected);
for amount in [0.01, 0.45, 1.0] {
let mut actual_p = p.clone();
let mut actual_q = q.clone();
guided_chroma(&g, [&mut actual_p, &mut actual_q], w, h, r, eps, amount);
for (original, actual) in [(&p, &actual_p), (&q, &actual_q)] {
let filtered = reference_guided(&g, original, w, h, r, eps);
let expected: Vec<f32> = original.iter().zip(filtered).map(|(&v, d)| v + (d - v) * amount).collect();
assert_bits(actual, &expected);
}
}
}
}
}
}
}
#[test]
fn reduce_noise_guides_both_chroma_planes_by_the_cleaned_luma() {
// Caller wiring: Lab keeps its planes as they are, so each output plane is compared with
// the independent filters run on the planes in order (luma by itself, chroma by the
// cleaned luma).
let (w, h) = (19, 13);
let rect = Rect::new(0, 0, w as i32, h as i32);
let mut src = Image::new(rect, 3);
for (i, px) in src.data.as_chunks_mut::<3>().0.iter_mut().enumerate() {
px[0] = (i * 37 % 101) as f32 / 100.0;
px[1] = (i * 13 % 71) as f32 / 70.0 - 0.5;
px[2] = (i * 29 % 83) as f32 / 41.0 - 1.0;
}
let ctx = Ctx { bounds: rect, mode: ColorMode::Lab, alpha: false };
let spec = DenoiseSpec { strength: 6.0, preserve: 30.0, color: 50.0, sharpen: 0.0, jpeg: false };
let out = reduce_noise(&src, rect, &ctx, &spec);
let plane = |c: usize| -> Vec<f32> { src.data.as_chunks::<3>().0.iter().map(|px| px[c]).collect() };
let (luma, chroma) = (plane(0), [plane(1), plane(2)]);
let s = spec.strength / 10.0;
let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - spec.preserve / 100.0);
let smooth = reference_guided(&luma, &luma, w, h, (1.0 + spec.strength * 0.4).round() as usize, eps);
let cleaned: Vec<f32> = luma.iter().zip(&smooth).map(|(&v, &d)| v + (d - v) * s.sqrt()).collect();
let col = spec.color / 100.0;
let chroma_r = (1.0 + col * 8.0).round() as usize;
let chroma_eps = (0.01 + 0.05 * col).powi(2);
let mut expected = vec![cleaned];
for p in &chroma {
let filtered = reference_guided(&expected[0], p, w, h, chroma_r, chroma_eps);
expected.push(p.iter().zip(filtered).map(|(&v, f)| v + (f - v) * col).collect());
}
for (c, expected) in expected.iter().enumerate() {
let actual: Vec<f32> = out.as_chunks::<3>().0.iter().map(|px| px[c]).collect();
assert_bits(&actual, expected);
assert!(actual.iter().zip(&plane(c)).any(|(a, o)| (a - o).abs() > 1e-3), "plane {c} was filtered");
}
}
fn assert_bits(actual: &[f32], expected: &[f32]) {
assert_eq!(actual.len(), expected.len());
for (i, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert_eq!(actual.to_bits(), expected.to_bits(), "pixel {i}: {actual} != {expected}");
}
}
}
+25 -4
View File
@@ -1,7 +1,7 @@
//! Interleaved `f32` image regions and sampling helpers.
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_raster::{Surface, scratch};
/// How samples outside an image are read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -14,8 +14,9 @@ pub enum Edge {
Wrap,
}
/// A rectangle of interleaved, normalized samples.
#[derive(Clone, Debug, PartialEq)]
/// A rectangle of interleaved, normalized samples. The samples come from, and go back to, the
/// scratch pool.
#[derive(Debug, PartialEq)]
pub struct Image {
pub rect: Rect,
/// Channels per pixel (alpha last when the surface has alpha).
@@ -23,6 +24,20 @@ pub struct Image {
pub data: Vec<f32>,
}
impl Clone for Image {
fn clone(&self) -> Self {
let mut data = scratch::take(self.data.len(), 0.0f32);
data.copy_from_slice(&self.data);
Image { rect: self.rect, ch: self.ch, data }
}
}
impl Drop for Image {
fn drop(&mut self) {
scratch::give(std::mem::take(&mut self.data));
}
}
impl Image {
/// Reads `rect` from a surface.
pub fn read(s: &Surface, rect: Rect) -> Self {
@@ -53,9 +68,15 @@ impl Image {
img
}
/// The samples, taken out of the image (it can't be destructured: it returns them to the
/// pool on drop).
pub fn into_data(mut self) -> Vec<f32> {
std::mem::take(&mut self.data)
}
/// New zeroed image.
pub fn new(rect: Rect, ch: usize) -> Self {
Image { rect, ch, data: vec![0.0; rect.width() as usize * rect.height() as usize * ch] }
Image { rect, ch, data: scratch::take(rect.width() as usize * rect.height() as usize * ch, 0.0) }
}
#[inline]
+275
View File
@@ -0,0 +1,275 @@
//! Filter › Render › Lens Flare.
//!
//! A flare is built from elements placed along its axis: the line from the flare centre (t = 0)
//! through the centre of the image (t = 1), as in a real lens, where the internal reflections line
//! up with the optical centre. Each lens has its own set, measured from Photoshop's output
//! (`lens_flare_data.rs`):
//!
//! - radial elements: a colour profile by distance around a point on the axis (the core, glows,
//! rings and the reflection discs, with their coloured rims);
//! - streaks: straight lines centred on the axis (Movie Prime's horizontal and diagonal streaks, the
//! 105 mm Prime's spike);
//! - rays: thin one-pixel rays out of the centre, drawn without antialiasing as Photoshop does.
//!
//! Sizes are in units of 1/800 of the image width: Photoshop sizes a flare by the width alone, the
//! same in landscape, square and portrait images. Brightness changes the shape of the elements, not
//! just their light (the core grows, the glows widen, at 300 % most lenses wash out to white), so
//! each lens is measured at several brightnesses; between two the flare blends them. The light is
//! screened onto the pixels.
use photocraft_geom::Rect;
use crate::LensType;
use crate::fxutil::{rgba, set_rgba, smoothstep};
use crate::image::Image;
use crate::{Ctx, lens_flare_data as data};
/// A colour profile by distance (in flare units) around a point on the axis; zero outside the
/// first and last knot, linear between knots.
pub(crate) struct Radial {
pub t: f32,
/// Offset across the axis, in flare units.
pub perp: f32,
pub knots: &'static [(f32, [f32; 3])],
}
/// A straight streak centred on the axis: full strength along `half · (1 − taper)`, fading to
/// nothing at `half`; a Gaussian `width` across.
pub(crate) struct Streak {
pub t: f32,
pub perp: f32,
/// Degrees, clockwise from the +x axis (image y points down).
pub angle: f32,
pub half: f32,
pub taper: f32,
pub width: f32,
pub rgb: [f32; 3],
}
/// A one-pixel ray out of the flare centre from radius `r0` to `r1`, fading in and out over
/// `fade` of its length.
pub(crate) struct Ray {
pub angle: f32,
pub r0: f32,
pub r1: f32,
pub fade: f32,
pub rgb: [f32; 3],
}
/// A lens's flare at one measured brightness (percent).
pub(crate) struct Level {
pub brightness: f32,
pub radial: &'static [Radial],
pub streaks: &'static [Streak],
pub rays: &'static [Ray],
}
fn levels(lens: LensType) -> &'static [Level] {
match lens {
LensType::Zoom => data::ZOOM,
LensType::Prime35 => data::PRIME35,
LensType::Prime105 => data::PRIME105,
LensType::MoviePrime => data::MOVIE_PRIME,
}
}
/// The two measured levels around `brightness` and the weight of the second (levels are sorted).
fn bracket(levels: &[Level], brightness: f32) -> Option<(&Level, &Level, f32)> {
let first = levels.first()?;
let last = levels.last()?;
if brightness <= first.brightness {
return Some((first, first, 0.0));
}
let i = levels.partition_point(|l| l.brightness < brightness);
let Some(hi) = levels.get(i) else { return Some((last, last, 0.0)) };
let Some(lo) = i.checked_sub(1).and_then(|j| levels.get(j)) else { return Some((hi, hi, 0.0)) };
let span = hi.brightness - lo.brightness;
Some((lo, hi, if span > 0.0 { (brightness - lo.brightness) / span } else { 0.0 }))
}
/// The profile at distance `d`; `None` outside it.
fn profile(knots: &[(f32, [f32; 3])], d: f32) -> Option<[f32; 3]> {
let (first, last) = (knots.first()?, knots.last()?);
if d < first.0 || d > last.0 {
return None;
}
// The first knot at or beyond `d` (binary search: profiles have up to a few hundred knots).
let i = knots.partition_point(|k| k.0 < d);
let Some(hi) = knots.get(i) else { return Some(last.1) };
let Some(lo) = i.checked_sub(1).and_then(|j| knots.get(j)) else { return Some(hi.1) };
let span = hi.0 - lo.0;
let f = if span > 0.0 { (d - lo.0) / span } else { 0.0 };
Some(std::array::from_fn(|k| lo.1[k] + (hi.1[k] - lo.1[k]) * f))
}
/// Flare geometry in document pixels, and the output tile it is drawn into.
struct Geometry {
centre: (f32, f32),
/// Axis: image centre minus flare centre.
axis: (f32, f32),
/// Unit normal to the axis.
normal: (f32, f32),
/// Pixels per flare unit.
unit: f32,
/// Output tile: origin and size.
origin: (f32, f32),
size: (usize, usize),
}
impl Geometry {
fn point(&self, t: f32, perp: f32) -> (f32, f32) {
(self.centre.0 + t * self.axis.0 + perp * self.unit * self.normal.0, self.centre.1 + t * self.axis.1 + perp * self.unit * self.normal.1)
}
}
/// Adds `weight` times the light of one level into `light` (the output tile, row-major).
fn add_level(level: &Level, weight: f32, g: &Geometry, light: &mut [[f32; 3]]) {
if weight <= 0.0 {
return;
}
let (w, h) = g.size;
let (ox, oy) = g.origin;
// Radial elements, each only over the pixels it can reach.
for e in level.radial {
let Some(reach) = e.knots.last().map(|k| k.0 * g.unit) else { continue };
let p = g.point(e.t, e.perp);
let x0 = ((p.0 - reach - ox).floor().max(0.0) as usize).min(w);
let x1 = ((p.0 + reach - ox).ceil().max(0.0) as usize).min(w);
let y0 = ((p.1 - reach - oy).floor().max(0.0) as usize).min(h);
let y1 = ((p.1 + reach - oy).ceil().max(0.0) as usize).min(h);
for y in y0..y1 {
let fy = oy + y as f32 + 0.5 - p.1;
for x in x0..x1 {
let fx = ox + x as f32 + 0.5 - p.0;
if let Some(v) = profile(e.knots, fx.hypot(fy) / g.unit)
&& let Some(px) = light.get_mut(y * w + x)
{
for (p, v) in px.iter_mut().zip(v) {
*p += weight * v;
}
}
}
}
}
for s in level.streaks {
let p = g.point(s.t, s.perp);
let (sin, cos) = s.angle.to_radians().sin_cos();
let half = s.half.max(1e-3);
let taper = s.taper.clamp(0.01, 1.0);
let width = s.width.max(1e-3);
for (i, px) in light.iter_mut().enumerate() {
let fx = ox + (i % w) as f32 + 0.5 - p.0;
let fy = oy + (i / w) as f32 + 0.5 - p.1;
let across = (-fx * sin + fy * cos) / g.unit / width;
// NaN comes from overflowing geometry and must not reach the pixels.
if across.is_nan() || across.abs() > 4.0 {
continue;
}
let along = ((fx * cos + fy * sin) / g.unit).abs();
let k = weight * (1.0 - smoothstep(half * (1.0 - taper), half, along)) * (-across * across).exp();
for (p, c) in px.iter_mut().zip(s.rgb) {
*p += k * c;
}
}
}
// Rays: walk each ray in short steps and light every pixel it crosses once.
for r in level.rays {
let (sin, cos) = r.angle.to_radians().sin_cos();
let (r0, r1) = (r.r0 * g.unit, r.r1 * g.unit);
let length = (r1 - r0).max(1e-6);
let fade = r.fade.max(1e-3);
// Skip a ray whose segment misses this tile: none of its samples can land inside it.
let (sx, sy) = (g.centre.0 + r0 * cos, g.centre.1 + r0 * sin);
let (ex, ey) = (g.centre.0 + r1 * cos, g.centre.1 + r1 * sin);
if sx.max(ex) < ox - 1.0 || sx.min(ex) > ox + w as f32 + 1.0 || sy.max(ey) < oy - 1.0 || sy.min(ey) > oy + h as f32 + 1.0 {
continue;
}
let steps = ((r1 - r0) / 0.35).ceil().clamp(0.0, 1e6) as usize;
let mut last = None;
for i in 0..steps {
let rad = r0 + i as f32 * 0.35;
let (px, py) = ((g.centre.0 + rad * cos).floor(), (g.centre.1 + rad * sin).floor());
if last == Some((px, py)) {
continue;
}
last = Some((px, py));
let (lx, ly) = (px - ox, py - oy);
if lx < 0.0 || ly < 0.0 || lx >= w as f32 || ly >= h as f32 {
continue;
}
let pos = (rad - r0) / length;
let env = weight * ((pos.min(1.0 - pos)) / fade).clamp(0.0, 1.0);
if let Some(v) = light.get_mut(ly as usize * w + lx as usize) {
for (p, c) in v.iter_mut().zip(r.rgb) {
*p += env * c;
}
}
}
}
}
pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, centre: (f32, f32), lens: LensType) -> Vec<f32> {
let n = src.ch;
let b = ctx.bounds;
let (bw, bh) = (b.width() as f32, b.height() as f32);
let c = (b.x0 as f32 + centre.0 * bw, b.y0 as f32 + centre.1 * bh);
let mid = (b.x0 as f32 + bw / 2.0, b.y0 as f32 + bh / 2.0);
let axis = (mid.0 - c.0, mid.1 - c.1);
let len = axis.0.hypot(axis.1);
// A centre so far off the canvas that the geometry overflows f32 (e.g. 1e300 from a command)
// lights nothing here; without this, NaN would reach every pixel through the streaks.
if ![c.0, c.1, len].iter().all(|v| v.is_finite()) {
return src.crop(out);
}
// At the image centre the axis has no direction; the offsets across it are tiny anyway.
let normal = if len > 1e-6 { (-axis.1 / len, axis.0 / len) } else { (0.0, 1.0) };
let (w, h) = (out.width() as usize, out.height() as usize);
let g = Geometry { centre: c, axis, normal, unit: (bw / 800.0).max(1e-6), origin: (out.x0 as f32, out.y0 as f32), size: (w, h) };
let mut light = vec![[0.0f32; 3]; w * h];
if let Some((lo, hi, f)) = bracket(levels(lens), brightness.clamp(10.0, 300.0)) {
add_level(lo, 1.0 - f, &g, &mut light);
if !std::ptr::eq(lo, hi) {
add_level(hi, f, &g, &mut light);
}
}
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let Some(f) = light.get(i) else { break };
let mut c = rgba(ctx, px);
for k in 0..3 {
// `clamp` keeps NaN; a non-finite amount adds no light.
let a = if f[k].is_finite() { f[k].clamp(0.0, 1.0) } else { 0.0 };
// Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit value
// already above white is left as is rather than pulled down towards it.
c[k] += a * (1.0 - c[k]).max(0.0);
}
set_rgba(ctx, px, c);
}
res
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn brightness_between_levels_blends_the_two_around_it() {
let lv = levels(LensType::Zoom);
let (a, b) = (&lv[0], &lv[1]);
let mid = (a.brightness + b.brightness) / 2.0;
let (lo, hi, f) = bracket(lv, mid).unwrap();
assert!(std::ptr::eq(lo, a) && std::ptr::eq(hi, b) && (f - 0.5).abs() < 1e-6);
// Exactly on a level, and outside the measured range, one level is used as is.
let (_, hi, f) = bracket(lv, b.brightness).unwrap();
assert!(std::ptr::eq(hi, b) && f == 1.0);
let (lo, hi, f) = bracket(lv, 1.0).unwrap();
assert!(std::ptr::eq(lo, a) && std::ptr::eq(hi, a) && f == 0.0);
let last = lv.last().unwrap();
let (lo, _, f) = bracket(lv, 1000.0).unwrap();
assert!(std::ptr::eq(lo, last) && f == 0.0);
}
}
File diff suppressed because it is too large. Load diff
+48 -2
View File
@@ -26,6 +26,7 @@ mod blur2;
pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod cutout;
mod denoise;
pub mod dff;
pub mod direct;
@@ -42,6 +43,8 @@ pub mod histogram;
mod image;
pub mod inpaint;
pub mod lens;
mod lens_flare;
mod lens_flare_data;
pub mod liquify;
pub mod magnetic;
pub mod matting;
@@ -72,6 +75,7 @@ pub mod seam;
pub mod segment;
pub mod selection;
mod selection_blur;
pub mod separable;
mod shape_blur;
mod sharpen;
pub mod stack;
@@ -564,6 +568,9 @@ impl FilterParams {
pub fn halo(&self) -> Halo {
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
match self {
// Small Gaussian radii use the calibrated seven-tap kernel as well: its reach is
// three pixels even when ceil(3 * radius) + 1 would be smaller.
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } if (0.05..1.0).contains(radius) => Halo::Radius(3),
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
FilterParams::SmartSharpen { radius, .. } => g(*radius),
@@ -730,6 +737,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::PathBlur { paths } => r(paths.iter().map(|p| p.speed.abs()).fold(0.0, f32::max) / 2.0 + 1.0),
FilterParams::Custom { .. } => r(2.0),
FilterParams::DeInterlace { .. } => r(1.0),
FilterParams::FilterGallery { effects } if artistic_fx::is_global(effects) => Halo::Bounds,
FilterParams::FilterGallery { effects } => Halo::Radius(artistic_fx::reach(effects)),
_ => Halo::Radius(0),
}
@@ -840,7 +848,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
}
FilterParams::LensFlare { brightness, center_x, center_y, lens } => render::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens),
FilterParams::LensFlare { brightness, center_x, center_y, lens } => lens_flare::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens),
FilterParams::LightingEffects { lights, gloss, metallic, exposure, ambience, texture, height, white_is_high } => render::lighting(
src,
out,
@@ -1017,6 +1025,31 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
}
}
/// Conservative transient allocation estimate for filter admission. Output tiles remain in
/// the paged store; whole-image kernels must admit their float input before reading it.
pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels: usize) -> u64 {
if area.is_empty() {
return 0;
}
#[cfg(not(target_arch = "wasm32"))]
let threads = rayon::current_num_threads().max(1) as u64;
#[cfg(target_arch = "wasm32")]
let threads = 1u64;
let tile = auto_tile(params, bounds).max(1) as u64;
let floats = (channels as u64).saturating_mul(4);
// Result groups hold at most RESULT_BUDGET bytes of floats, and never more than the area.
let base = area.size().area().saturating_mul(floats).min(RESULT_BUDGET as u64).saturating_mul(3);
match params.halo_for(bounds) {
Halo::Bounds => base.saturating_add(bounds.union(&area).size().area().saturating_mul(floats)),
Halo::Radius(r) => {
let side = tile.saturating_add((r.max(0) as u64).saturating_mul(2));
base.saturating_add(
side.saturating_mul(side).saturating_add(tile.saturating_mul(tile).saturating_mul(3)).saturating_mul(floats).saturating_mul(threads),
)
}
}
}
/// [`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(
@@ -1120,6 +1153,14 @@ fn apply_tiled_with_impl(
}
}
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
// A Filter Gallery stack with a Cutout traces the whole image once; tiles only fill it in.
let gallery = match (params, &shared) {
(FilterParams::FilterGallery { effects }, Some(src)) => artistic_fx::Prepared::new(effects, src, &ctx),
_ => None,
};
if ctl.cancelled() {
return None;
}
// Shape rasterization depends only on params, not on a tile's pixels.
let shape = match params {
FilterParams::ShapeBlur { radius, shape } => {
@@ -1197,6 +1238,7 @@ fn apply_tiled_with_impl(
}
};
let Some(mut data) = (match (&shape, &radial) {
_ if gallery.is_some() => gallery.as_ref().map(|g| g.run(src, *t, &ctx)),
(Some(shape), _) => shape.checked_run(src, *t, &ctx),
(None, Some(plan)) => match plan.filter(src, *t, &ctx, ctl) {
Some(data) => Some(data),
@@ -1254,7 +1296,10 @@ fn apply_tiled_with_impl(
}
for (t, data) in results {
match data {
TileData::Samples(data) => out.write_region(t, &data),
TileData::Samples(data) => {
out.write_region(t, &data);
photocraft_raster::scratch::give(data);
}
TileData::Encoded(data) => out.write_interleaved(t, &data),
}
}
@@ -1309,6 +1354,7 @@ fn apply_box_blur(
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
}
s.write_region(*t, data);
photocraft_raster::scratch::give(std::mem::take(data));
}
};
#[cfg(not(target_arch = "wasm32"))]
+6 -2
View File
@@ -33,6 +33,9 @@ pub enum LiquifyTool {
Reconstruct,
Smooth,
TwirlCw,
/// Twirl Clockwise with Alt (Option) held: a stroke's direction, not a tool of its own. The
/// reference application's Liquify has Twirl Clockwise only; strokes may still name
/// `twirlCcw` (Alt-strokes record it, and older scripts and saved strokes use it).
TwirlCcw,
Pucker,
Bloat,
@@ -49,12 +52,13 @@ pub enum LiquifyTool {
}
impl LiquifyTool {
pub const ALL: [LiquifyTool; 11] = [
/// The tool strip, in order. Twirl Counterclockwise is not on it: it is Twirl Clockwise with
/// Alt (Option) held.
pub const ALL: [LiquifyTool; 10] = [
LiquifyTool::ForwardWarp,
LiquifyTool::Reconstruct,
LiquifyTool::Smooth,
LiquifyTool::TwirlCw,
LiquifyTool::TwirlCcw,
LiquifyTool::Pucker,
LiquifyTool::Bloat,
LiquifyTool::PushLeft,
+53 -71
View File
@@ -35,8 +35,11 @@ pub struct RefineParams {
pub feather: f32,
/// 0–100.
pub contrast: f32,
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
/// −100–100. A curve on the refined coverage, applied last: each pixel's odds (`a / (1 − a)`)
/// are multiplied by `49^(shift / 100)`, so at −100% a half-covered pixel drops to 2% and at
/// +100% it rises to 98%. Fully selected and unselected pixels never change, so Shift Edge
/// moves only soft borders and a hard edge stays put (measured against the reference
/// application: the fit holds within ±0.05 in log-odds at feathers of 2, 5 and 10 px).
pub shift_edge: f32,
}
@@ -60,18 +63,10 @@ impl RefineParams {
fn feather_sigma(&self) -> f32 {
self.feather.max(0.0) / 2.0
}
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
fn shift_amount(&self) -> f32 {
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
}
/// How many pixels the mask can move: the farther of the two integer radii a fractional
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
fn shift_px(&self) -> i32 {
let px = self.shift_amount();
let reach = px.abs().ceil();
let signed = if px.is_sign_negative() { -reach } else { reach };
if signed.is_finite() { signed as i32 } else { 0 }
/// The factor Shift Edge multiplies each pixel's odds by: `49^(shift / 100)`, 1 at 0%.
fn shift_odds(&self) -> f32 {
let s = self.shift_edge.clamp(-100.0, 100.0) / 100.0;
if s.is_finite() { 49f32.powf(s) } else { 1.0 }
}
fn uses_image(&self) -> bool {
self.radius > 0.0
@@ -84,14 +79,13 @@ impl RefineParams {
} else {
0
};
edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + self.shift_px().abs() + 2
edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + 2
}
/// How far the refined mask can extend beyond the original.
pub fn spread(&self) -> i32 {
(if self.uses_image() { self.radius.ceil() as i32 } else { 0 })
+ (3.0 * self.smooth_sigma()).ceil() as i32
+ (3.0 * self.feather_sigma()).ceil() as i32
+ self.shift_px().max(0)
+ 1
}
}
@@ -307,7 +301,7 @@ pub fn edge_width(img: &RgbImage, r: usize) -> Vec<f32> {
/// Refines a soft mask buffer `m` (`w × h`) with `img` as guide (required when `radius > 0`).
pub fn refine_buffer(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams) -> Vec<f32> {
refine_buffer_with(img, m, w, h, p, None, morph)
refine_buffer_with(img, m, w, h, p, None)
}
/// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the
@@ -318,10 +312,7 @@ struct BrushBuf<'a> {
window: usize,
}
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
fn refine_buffer_with(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>) -> Vec<f32> {
let mut a = m.to_vec();
if let (true, Some(img)) = (p.uses_image(), img) {
let bin: Vec<bool> = m.iter().map(|v| *v >= 0.5).collect();
@@ -381,36 +372,23 @@ where
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
}
}
let amount = p.shift_amount();
if amount.abs() >= 1.0e-4 {
a = shift_mask(&a, w, h, amount, morphology);
let k = p.shift_odds();
if (k - 1.0).abs() >= 1.0e-6 {
for v in &mut a {
*v = shift_coverage(*v, k);
}
}
a
}
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
let mag = amount.abs();
if !mag.is_finite() || mag < 1.0e-4 {
return src.to_vec();
/// Shift Edge on one coverage value: its odds times `k`. 0 and 1 stay exact.
fn shift_coverage(v: f32, k: f32) -> f32 {
let v = v.clamp(0.0, 1.0);
if v <= 0.0 || v >= 1.0 {
return v;
}
let grow = amount > 0.0;
let whole = mag.floor() as usize;
let frac = mag - whole as f32;
if frac < 1.0e-4 {
return morphology(src, w, h, whole, grow);
}
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
let hi = morphology(src, w, h, whole + 1, grow);
let n = w.saturating_mul(h);
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
return hi;
}
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
let shifted = k * v / (k * v + (1.0 - v));
if shifted.is_finite() { shifted.clamp(0.0, 1.0) } else { v }
}
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
@@ -433,7 +411,7 @@ pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option<Region> {
refine_mask_with(sampler, mask, content, canvas, p, None, morph)
refine_mask_with(sampler, mask, content, canvas, p, None)
}
/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image
@@ -447,21 +425,17 @@ pub fn refine_mask_brushed(
p: &RefineParams,
brush: Option<&EdgeBrush>,
) -> Option<Region> {
refine_mask_with(sampler, mask, content, canvas, p, brush, morph)
refine_mask_with(sampler, mask, content, canvas, p, brush)
}
fn refine_mask_with<F>(
fn refine_mask_with(
sampler: &dyn Sampler,
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
content: Rect,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
morphology: F,
) -> Option<Region>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy + Sync + Send,
{
) -> Option<Region> {
let bbox = refine_area(content, canvas, p, brush);
if bbox.is_empty() {
return None;
@@ -501,7 +475,7 @@ where
(Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }),
_ => None,
};
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb, morphology);
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb);
(*t, core(&out))
};
#[cfg(not(target_arch = "wasm32"))]
@@ -765,22 +739,30 @@ mod tests {
assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1);
let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() });
assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0);
let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() });
assert_eq!(grown[5 * w + 33], 1.0);
assert_eq!(grown[5 * w + 34], 0.0);
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 27], 1.0);
assert_eq!(shrunk[5 * w + 28], 0.0);
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
// The pixel just outside a hard edge fades in instead of snapping on.
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
assert_eq!(m[5 * w + 30], 0.0);
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
assert_eq!(one[5 * w + 30], 1.0);
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
// Shift Edge leaves a hard edge where it is, at any amount: only soft coverage moves.
for shift_edge in [-100.0, -50.0, 50.0, 100.0] {
let s = refine_buffer(None, &m, w, h, &RefineParams { shift_edge, ..Default::default() });
assert_eq!(s, m, "Shift Edge {shift_edge}% moved a hard edge");
}
// Coverage before → after, read off the reference application's feathered edges.
for (shift_edge, before, after) in [
(-100.0, 0.898, 0.153),
(-100.0, 0.612, 0.031),
(-100.0, 0.965, 0.357),
(-50.0, 0.537, 0.141),
(-50.0, 0.898, 0.557),
(100.0, 0.102, 0.847),
(100.0, 0.388, 0.969),
] {
let flat = vec![before; w * h];
let s = refine_buffer(None, &flat, w, h, &RefineParams { shift_edge, ..Default::default() });
assert!((s[5 * w + 30] - after).abs() < 0.01, "{shift_edge}%: {before} became {}, expected {after}", s[5 * w + 30]);
}
// So a feathered edge moves: at −100% its half-covered point lands where it was 98%.
let soft = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, ..Default::default() });
let pulled = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, shift_edge: -100.0, ..Default::default() });
let half = |v: &[f32]| (0..w).find(|x| v[5 * w + x] < 0.5).unwrap_or(w);
assert!(half(&pulled) < half(&soft), "−100% did not pull the soft edge in");
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
}
@@ -230,65 +230,6 @@ fn octagonal_morphology_release_comparison() {
}
}
#[test]
#[ignore = "release comparison of the full tiled Select and Mask CPU path"]
#[allow(clippy::assertions_on_constants)]
fn tiled_refinement_release_comparison() {
use crate::{
matting::{RefineParams, refine_mask_with, region_reader},
segment::{ImageSampler, RgbImage, trim_region},
selection::Region,
};
use photocraft_geom::Rect;
use std::{hint::black_box, time::Instant};
assert!(!cfg!(debug_assertions), "run with --release");
for (w, h) in [(6000usize, 4000usize), (7200, 5000)] {
let image = RgbImage::from_fn(w, h, |x, y| {
let t = x as f32 / w as f32;
let u = y as f32 / h as f32;
[0.2 + 0.5 * t, 0.15 + 0.55 * u, 0.3 + 0.15 * ((x / 17 + y / 23) % 3) as f32]
});
let sampler = ImageSampler { img: &image, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
let mask: Vec<_> = (0..w * h)
.map(|i| {
let dx = (i % w) as f64 - w as f64 * 0.51;
let dy = (i / w) as f64 - h as f64 * 0.48;
let d = (dx * dx + dy * dy).sqrt();
((0.5 + (w.min(h) as f64 * 0.27 - d) / 7.0).clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect();
let region = trim_region(Region { bbox: canvas, mask }).unwrap();
let reader = region_reader(&region);
let p = RefineParams { radius: 64.0, shift_edge: 100.0, ..Default::default() };
let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, repeated).unwrap();
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, morph).unwrap();
assert_eq!(new.bbox, old.bbox);
assert_eq!(new.mask, old.mask);
let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new());
for run in 0..3 {
let mut measure = |fast: bool| {
let start = Instant::now();
let out = if fast {
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, morph)
} else {
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated)
}
.unwrap();
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
assert_eq!(out.bbox, old.bbox);
assert_eq!(out.mask, old.mask);
if fast { new_ms.push(elapsed) } else { old_ms.push(elapsed) }
};
measure(run % 2 == 0);
measure(run % 2 != 0);
}
old_ms.sort_by(f64::total_cmp);
new_ms.sort_by(f64::total_cmp);
println!("REFINE {w}x{h} radius=64 shift=100 old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2}", old_ms[1] / new_ms[1]);
}
}
#[test]
#[ignore = "release comparison on a dense fractional 24 MP matte"]
#[allow(clippy::assertions_on_constants)]
+225 -52
View File
@@ -62,68 +62,213 @@ impl Normal {
}
}
/// A sparse least-squares problem `min Σ w²(row·x − rhs)²`, solved by conjugate gradients on the
/// normal equations (CGLS). Used for mesh warps with thousands of unknowns.
#[derive(Default)]
pub(crate) struct SparseLs {
pub n: usize,
rows: Vec<(Vec<(usize, f64)>, f64)>,
/// A compressed sparse row matrix (symmetric, used by CG).
pub(crate) struct Csr {
pub rows: Vec<usize>,
pub cols: Vec<usize>,
pub vals: Vec<f64>,
pub diag: Vec<f64>,
}
impl SparseLs {
pub fn new(n: usize) -> Self {
SparseLs { n, rows: Vec::new() }
}
pub fn add(&mut self, row: Vec<(usize, f64)>, rhs: f64, w: f64) {
self.rows.push((row.into_iter().map(|(i, c)| (i, c * w)).collect(), rhs * w));
}
/// CGLS from `x0`; at most `iters` iterations.
pub fn solve(&self, x0: &[f64], iters: usize) -> Vec<f64> {
let n = self.n;
let mut x = x0.to_vec();
let ax = |x: &[f64]| -> Vec<f64> { self.rows.iter().map(|(r, _)| r.iter().map(|&(i, c)| c * x[i]).sum()).collect() };
let atv = |v: &[f64]| -> Vec<f64> {
let mut out = vec![0.0; n];
for ((r, _), vi) in self.rows.iter().zip(v) {
for &(i, c) in r {
out[i] += c * vi;
impl Csr {
pub fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr {
t.sort_by_key(|a| (a.0, a.1));
let mut rows = vec![0usize; n + 1];
let mut cols = Vec::with_capacity(t.len());
let mut vals: Vec<f64> = Vec::with_capacity(t.len());
let mut last = (usize::MAX, usize::MAX);
for (i, j, v) in t {
if (i, j) == last {
*vals.last_mut().unwrap_or(&mut 0.0) += v;
continue;
}
last = (i, j);
cols.push(j);
vals.push(v);
rows[i + 1] = cols.len();
}
for i in 0..n {
rows[i + 1] = rows[i + 1].max(rows[i]);
}
let mut diag = vec![1.0; n];
for (i, d) in diag.iter_mut().enumerate() {
for k in rows[i]..rows[i + 1] {
if cols[k] == i && vals[k] > 0.0 {
*d = vals[k];
}
}
out
};
let mut r: Vec<f64> = self.rows.iter().zip(ax(&x)).map(|((_, b), a)| b - a).collect();
let mut s = atv(&r);
let mut p = s.clone();
let mut gamma: f64 = s.iter().map(|v| v * v).sum();
let g0 = gamma.max(1e-30);
for _ in 0..iters {
if gamma <= g0 * 1e-20 {
}
Csr { rows, cols, vals, diag }
}
pub fn mul(&self, x: &[f64], out: &mut [f64]) {
for (i, o) in out.iter_mut().enumerate() {
let mut s = 0.0;
for k in self.rows[i]..self.rows[i + 1] {
s += self.vals[k] * x[self.cols[k]];
}
*o = s;
}
}
/// Jacobi-preconditioned conjugate gradients, warm-started from `x`.
pub fn solve(&self, b: &[f64], x: &mut [f64]) {
let n = b.len();
let mut r = vec![0.0; n];
self.mul(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
let bn: f64 = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
let mut z: Vec<f64> = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect();
let mut p = z.clone();
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let mut ap = vec![0.0; n];
for _ in 0..(4 * n).clamp(50, 2000) {
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-12 * bn {
break;
}
let q = ax(&p);
let qq: f64 = q.iter().map(|v| v * v).sum();
if qq <= 1e-300 {
self.mul(&p, &mut ap);
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
if pap.abs() < 1e-300 {
break;
}
let alpha = gamma / qq;
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
for (ri, qi) in r.iter_mut().zip(&q) {
*ri -= alpha * qi;
}
s = atv(&r);
let g1: f64 = s.iter().map(|v| v * v).sum();
let beta = g1 / gamma;
gamma = g1;
for i in 0..n {
p[i] = s[i] + beta * p[i];
z[i] = r[i] / self.diag[i];
}
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
x
}
}
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
pub(crate) struct BandChol {
n: usize,
p: usize,
/// Row `i` holds `L[i][i - p ..= i]`.
l: Vec<f64>,
}
impl BandChol {
/// `None` when the band would be too large (or the matrix isn't positive definite).
pub fn new(a: &Csr) -> Option<BandChol> {
let n = a.diag.len();
let mut p = 0;
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
p = p.max(i.abs_diff(a.cols[k]));
}
}
if n.saturating_mul(p + 1) > 40_000_000 {
return None;
}
let w = p + 1;
let mut l = vec![0.0f64; n * w];
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
let j = a.cols[k];
if j <= i {
l[i * w + (j + p - i)] = a.vals[k];
}
}
}
for i in 0..n {
let lo = i.saturating_sub(p);
for j in lo..=i {
let jlo = j.saturating_sub(p).max(lo);
let mut s = l[i * w + (j + p - i)];
for k in jlo..j {
s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)];
}
if i == j {
if s <= 0.0 || !s.is_finite() {
return None;
}
l[i * w + p] = s.sqrt();
} else {
l[i * w + (j + p - i)] = s / l[j * w + p];
}
}
}
Some(BandChol { n, p, l })
}
#[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices
pub fn solve(&self, b: &[f64], x: &mut [f64]) {
let (n, p, w) = (self.n, self.p, self.p + 1);
let mut y = b.to_vec();
for i in 0..n {
let mut s = y[i];
for k in i.saturating_sub(p)..i {
s -= self.l[i * w + (k + p - i)] * y[k];
}
y[i] = s / self.l[i * w + p];
}
for i in (0..n).rev() {
let mut s = y[i];
for k in i + 1..(i + p + 1).min(n) {
s -= self.l[k * w + (i + p - k)] * x[k];
}
x[i] = s / self.l[i * w + p];
}
}
}
/// Preconditioned conjugate gradients on the SPD system `A x = b`, warm-started from `x`.
/// `a(v, out)` writes `A·v` and `m(r, out)` writes `M⁻¹·r`. Stops when `‖r‖ ≤ tol·‖b‖`, when the
/// system stops being usable (non-finite or non-positive curvature), or after `max_iter`
/// iterations. Returns the iterations used.
pub(crate) fn pcg(a: impl Fn(&[f64], &mut [f64]), m: impl Fn(&[f64], &mut [f64]), b: &[f64], x: &mut [f64], tol: f64, max_iter: usize) -> usize {
let dot = |u: &[f64], v: &[f64]| -> f64 { u.iter().zip(v).map(|(p, q)| p * q).sum() };
let n = b.len().min(x.len());
let mut r = vec![0.0; n];
a(x, &mut r);
for (ri, bi) in r.iter_mut().zip(b) {
*ri = bi - *ri;
}
let stop = tol * dot(b, b).sqrt().max(1e-30);
let mut z = vec![0.0; n];
m(&r, &mut z);
let mut p = z.clone();
let mut rz = dot(&r, &z);
let mut ap = vec![0.0; n];
for it in 0..max_iter {
if dot(&r, &r).sqrt() <= stop || rz <= 1e-300 || !rz.is_finite() {
return it;
}
a(&p, &mut ap);
let pap = dot(&p, &ap);
if pap <= 1e-300 || !pap.is_finite() {
return it;
}
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
m(&r, &mut z);
let rz2 = dot(&r, &z);
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
max_iter
}
/// Runs `f(y, row)` for every row of a `w × h × ch` buffer, in parallel on native targets.
pub(crate) fn par_rows<T: Send + Sync>(buf: &mut [T], w: usize, ch: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
let stride = (w * ch).max(1);
@@ -251,22 +396,50 @@ mod tests {
}
#[test]
fn dense_and_sparse_solvers_agree() {
fn dense_solver_fits_a_line_and_rejects_singular_systems() {
// Overdetermined line fit y = 2x + 1.
let mut ne = Normal::new(2);
let mut sp = SparseLs::new(2);
for x in 0..10 {
let y = 2.0 * x as f64 + 1.0;
ne.add(&[(0, x as f64), (1, 1.0)], y, 1.0);
sp.add(vec![(0, x as f64), (1, 1.0)], y, 1.0);
ne.add(&[(0, x as f64), (1, 1.0)], 2.0 * x as f64 + 1.0, 1.0);
}
let a = ne.solve().unwrap();
let b = sp.solve(&[0.0, 0.0], 50);
assert!((a[0] - 2.0).abs() < 1e-9 && (a[1] - 1.0).abs() < 1e-9);
assert!((b[0] - 2.0).abs() < 1e-6 && (b[1] - 1.0).abs() < 1e-6, "{b:?}");
assert!(solve_dense(2, vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 2.0]).is_none());
}
/// A tridiagonal SPD system: identity preconditioning needs many iterations, the exact
/// banded Cholesky preconditioner one or two; both match the dense solve.
#[test]
fn pcg_matches_the_dense_solve_and_an_exact_preconditioner_converges_at_once() {
let n = 40;
let mut trip = Vec::new();
for i in 0..n {
trip.push((i, i, 2.5));
if i + 1 < n {
trip.push((i, i + 1, -1.0));
trip.push((i + 1, i, -1.0));
}
}
let k = Csr::from_triplets(n, trip);
let mut dense = vec![0.0; n * n];
for i in 0..n {
for e in k.rows[i]..k.rows[i + 1] {
dense[i * n + k.cols[e]] = k.vals[e];
}
}
let b: Vec<f64> = (0..n).map(|i| (i as f64 * 0.7).sin()).collect();
let want = solve_dense(n, dense, b.clone()).unwrap();
let mul = |v: &[f64], out: &mut [f64]| k.mul(v, out);
let mut x = vec![0.0; n];
let slow = pcg(mul, |r, z| z.copy_from_slice(r), &b, &mut x, 1e-12, 500);
assert!(x.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "identity-preconditioned result differs");
let chol = BandChol::new(&k).unwrap();
let mut y = vec![0.0; n];
let fast = pcg(mul, |r, z| chol.solve(r, z), &b, &mut y, 1e-12, 500);
assert!(y.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "cholesky-preconditioned result differs");
assert!(fast <= 2 && slow > fast, "iterations: exact {fast}, identity {slow}");
}
#[test]
fn srgb_round_trip_and_sampling() {
for v in [0.0f32, 0.01, 0.2, 0.5, 1.0] {
+1 -164
View File
@@ -26,6 +26,7 @@ use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use crate::photo_util::{BandChol, Csr};
use crate::transform::Interp;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -744,170 +745,6 @@ fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec<usize> {
order
}
/// A compressed sparse row matrix (symmetric, used by CG).
struct Csr {
rows: Vec<usize>,
cols: Vec<usize>,
vals: Vec<f64>,
diag: Vec<f64>,
}
impl Csr {
fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr {
t.sort_by_key(|a| (a.0, a.1));
let mut rows = vec![0usize; n + 1];
let mut cols = Vec::with_capacity(t.len());
let mut vals: Vec<f64> = Vec::with_capacity(t.len());
let mut last = (usize::MAX, usize::MAX);
for (i, j, v) in t {
if (i, j) == last {
*vals.last_mut().unwrap_or(&mut 0.0) += v;
continue;
}
last = (i, j);
cols.push(j);
vals.push(v);
rows[i + 1] = cols.len();
}
for i in 0..n {
rows[i + 1] = rows[i + 1].max(rows[i]);
}
let mut diag = vec![1.0; n];
for (i, d) in diag.iter_mut().enumerate() {
for k in rows[i]..rows[i + 1] {
if cols[k] == i && vals[k] > 0.0 {
*d = vals[k];
}
}
}
Csr { rows, cols, vals, diag }
}
fn mul(&self, x: &[f64], out: &mut [f64]) {
for (i, o) in out.iter_mut().enumerate() {
let mut s = 0.0;
for k in self.rows[i]..self.rows[i + 1] {
s += self.vals[k] * x[self.cols[k]];
}
*o = s;
}
}
/// Jacobi-preconditioned conjugate gradients, warm-started from `x`.
fn solve(&self, b: &[f64], x: &mut [f64]) {
let n = b.len();
let mut r = vec![0.0; n];
self.mul(x, &mut r);
for i in 0..n {
r[i] = b[i] - r[i];
}
let bn: f64 = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
let mut z: Vec<f64> = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect();
let mut p = z.clone();
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let mut ap = vec![0.0; n];
for _ in 0..(4 * n).clamp(50, 2000) {
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-12 * bn {
break;
}
self.mul(&p, &mut ap);
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
if pap.abs() < 1e-300 {
break;
}
let alpha = rz / pap;
for i in 0..n {
x[i] += alpha * p[i];
r[i] -= alpha * ap[i];
}
for i in 0..n {
z[i] = r[i] / self.diag[i];
}
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let beta = rz2 / rz;
rz = rz2;
for i in 0..n {
p[i] = z[i] + beta * p[i];
}
}
}
}
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
struct BandChol {
n: usize,
p: usize,
/// Row `i` holds `L[i][i - p ..= i]`.
l: Vec<f64>,
}
impl BandChol {
/// `None` when the band would be too large (or the matrix isn't positive definite).
fn new(a: &Csr) -> Option<BandChol> {
let n = a.diag.len();
let mut p = 0;
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
p = p.max(i.abs_diff(a.cols[k]));
}
}
if n.saturating_mul(p + 1) > 40_000_000 {
return None;
}
let w = p + 1;
let mut l = vec![0.0f64; n * w];
for i in 0..n {
for k in a.rows[i]..a.rows[i + 1] {
let j = a.cols[k];
if j <= i {
l[i * w + (j + p - i)] = a.vals[k];
}
}
}
for i in 0..n {
let lo = i.saturating_sub(p);
for j in lo..=i {
let jlo = j.saturating_sub(p).max(lo);
let mut s = l[i * w + (j + p - i)];
for k in jlo..j {
s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)];
}
if i == j {
if s <= 0.0 || !s.is_finite() {
return None;
}
l[i * w + p] = s.sqrt();
} else {
l[i * w + (j + p - i)] = s / l[j * w + p];
}
}
}
Some(BandChol { n, p, l })
}
#[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices
fn solve(&self, b: &[f64], x: &mut [f64]) {
let (n, p, w) = (self.n, self.p, self.p + 1);
let mut y = b.to_vec();
for i in 0..n {
let mut s = y[i];
for k in i.saturating_sub(p)..i {
s -= self.l[i * w + (k + p - i)] * y[k];
}
y[i] = s / self.l[i * w + p];
}
for i in (0..n).rev() {
let mut s = y[i];
for k in i + 1..(i + p + 1).min(n) {
s -= self.l[k * w + (i + p - k)] * x[k];
}
x[i] = s / self.l[i * w + p];
}
}
}
/// A pin bound to the mesh: barycentric weights on a triangle's vertices.
#[derive(Clone, Debug)]
struct Bound {
Loaded 100 of 1187 files, more files were not shown because too many files have changed in this diff. Show more